From 452528bf19481f8cd00530086cb0019ea284bfb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agn=C3=A8s=20Toulet?= <35176601+AgnesToulet@users.noreply.github.com> Date: Wed, 19 May 2021 08:59:34 +0200 Subject: [PATCH] Add CSV export feature (#217) * POC: Add CSV export * Export CSV refactor - Wait for file to be downloaded - Delete tmp file and folder after * fix for gRPC mode * remove unnecessary dep * return CSV file name * fix wait function * fix for Linux * fix download timeout * delete tmp folder if a filePath is provided * undo dev change * separate render and renderCSV features + fix reusable and clustered mode for csv * fix RenderCSVRequest proto * fixes after merge * add await * fix function name --- package.json | 1 + proto/rendererv2.proto | 16 ++++ src/browser/browser.ts | 154 +++++++++++++++++++++++++++++------ src/browser/clustered.ts | 37 +++++++-- src/browser/reusable.ts | 32 +++++++- src/plugin/v2/grpc_plugin.ts | 44 +++++++++- src/plugin/v2/types.ts | 17 ++++ src/service/http-server.ts | 63 +++++++++++++- yarn.lock | 57 +++++++++++-- 9 files changed, 378 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index d96ca395..6a151d0e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@grpc/grpc-js": "^1.0", "@grpc/proto-loader": "^0.5.4", "@hapi/boom": "^9.1.0", + "chokidar": "^3.5.1", "eslint": "^7.13.0", "express": "^4.16.3", "express-prom-bundle": "^5.1.5", diff --git a/proto/rendererv2.proto b/proto/rendererv2.proto index a32fcb81..5baac929 100644 --- a/proto/rendererv2.proto +++ b/proto/rendererv2.proto @@ -24,6 +24,22 @@ message RenderResponse { string error = 1; } +message RenderCSVRequest { + string url = 1; + string filePath = 2; + string renderKey = 3; + string domain = 4; + int32 timeout = 5; + string timezone = 6; + map headers = 7; +} + +message RenderCSVResponse { + string error = 1; + string fileName = 2; +} + service Renderer { rpc Render(RenderRequest) returns (RenderResponse); + rpc RenderCSV(RenderCSVRequest) returns (RenderCSVResponse); } diff --git a/src/browser/browser.ts b/src/browser/browser.ts index b5517f12..12e2265d 100644 --- a/src/browser/browser.ts +++ b/src/browser/browser.ts @@ -1,6 +1,9 @@ import * as os from 'os'; import * as uniqueFilename from 'unique-filename'; import * as puppeteer from 'puppeteer'; +import * as chokidar from 'chokidar'; +import * as path from 'path'; +import * as fs from 'fs'; import { Logger } from '../logger'; import { RenderingConfig } from '../config'; @@ -23,10 +26,26 @@ export interface RenderOptions { headers?: HTTPHeaders; } +export interface RenderCSVOptions { + url: string; + filePath: string; + timeout: string | number; + renderKey: string; + domain: string; + timezone?: string; + encoding?: string; + headers?: HTTPHeaders; +} + export interface RenderResponse { filePath: string; } +export interface RenderCSVResponse { + filePath: string; + fileName?: string; +} + export class Browser { constructor(protected config: RenderingConfig, protected log: Logger) { this.log.debug('Browser initialized', 'config', this.config); @@ -48,15 +67,31 @@ export class Browser { async start(): Promise {} - validateOptions(options: RenderOptions) { + validateRenderOptions(options: RenderOptions | RenderCSVOptions) { if (options.url.startsWith(`socket://`)) { // Puppeteer doesn't support socket:// URLs throw new Error(`Image rendering in socket mode is not supported`); } + options.headers = options.headers || {}; + const headers = {}; + + if (options.headers['Accept-Language']) { + headers['Accept-Language'] = options.headers['Accept-Language']; + } else if (this.config.acceptLanguage) { + headers['Accept-Language'] = this.config.acceptLanguage; + } + + options.headers = headers; + + options.timeout = parseInt(options.timeout as string, 10) || 30; + } + + validateImageOptions(options: RenderOptions) { + this.validateRenderOptions(options); + options.width = parseInt(options.width as string, 10) || this.config.width; options.height = parseInt(options.height as string, 10) || this.config.height; - options.timeout = parseInt(options.timeout as string, 10) || 30; if (options.width < 10) { options.width = this.config.width; @@ -79,17 +114,6 @@ export class Browser { if (options.deviceScaleFactor > this.config.maxDeviceScaleFactor) { options.deviceScaleFactor = this.config.deviceScaleFactor; } - - options.headers = options.headers || {}; - const headers = {}; - - if (options.headers['Accept-Language']) { - headers['Accept-Language'] = options.headers['Accept-Language']; - } else if (this.config.acceptLanguage) { - headers['Accept-Language'] = this.config.acceptLanguage; - } - - options.headers = headers; } getLauncherOptions(options) { @@ -111,12 +135,28 @@ export class Browser { return launcherOptions; } + async preparePage(page: any, options: any) { + if (this.config.verboseLogging) { + this.log.debug('Setting cookie for page', 'renderKey', options.renderKey, 'domain', options.domain); + } + await page.setCookie({ + name: 'renderKey', + value: options.renderKey, + domain: options.domain, + }); + + if (options.headers && Object.keys(options.headers).length > 0) { + this.log.debug(`Setting extra HTTP headers for page`, 'headers', options.headers); + await page.setExtraHTTPHeaders(options.headers); + } + } + async render(options: RenderOptions): Promise { let browser; let page: any; try { - this.validateOptions(options); + this.validateImageOptions(options); const launcherOptions = this.getLauncherOptions(options); browser = await puppeteer.launch(launcherOptions); page = await browser.newPage(); @@ -152,19 +192,7 @@ export class Browser { deviceScaleFactor: options.deviceScaleFactor, }); - if (this.config.verboseLogging) { - this.log.debug('Setting cookie for page', 'renderKey', options.renderKey, 'domain', options.domain); - } - await page.setCookie({ - name: 'renderKey', - value: options.renderKey, - domain: options.domain, - }); - - if (options.headers && Object.keys(options.headers).length > 0) { - this.log.debug(`Setting extra HTTP headers for page`, 'headers', options.headers); - await page.setExtraHTTPHeaders(options.headers); - } + await this.preparePage(page, options); if (this.config.verboseLogging) { this.log.debug('Moving mouse on page', 'x', options.width, 'y', options.height); @@ -202,6 +230,78 @@ export class Browser { return { filePath: options.filePath }; } + async renderCSV(options: RenderCSVOptions): Promise { + let browser; + let page: any; + + try { + this.validateRenderOptions(options); + const launcherOptions = this.getLauncherOptions(options); + browser = await puppeteer.launch(launcherOptions); + page = await browser.newPage(); + this.addPageListeners(page); + + return await this.exportCSV(page, options); + } finally { + if (page) { + this.removePageListeners(page); + await page.close(); + } + if (browser) { + await browser.close(); + } + } + } + + async exportCSV(page: any, options: any): Promise { + await this.preparePage(page, options); + + const downloadPath = uniqueFilename(os.tmpdir()); + fs.mkdirSync(downloadPath); + const watcher = chokidar.watch(downloadPath); + let downloadFilePath = ''; + watcher.on('add', file => { + if (!file.endsWith('.crdownload')) { + downloadFilePath = file; + } + }); + + await page._client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath: downloadPath }); + + if (this.config.verboseLogging) { + this.log.debug('Navigating and waiting for all network requests to finish', 'url', options.url); + } + + await page.goto(options.url, { waitUntil: 'networkidle0', timeout: options.timeout * 1000 }); + + if (this.config.verboseLogging) { + this.log.debug('Waiting for download to end'); + } + + const startDate = Date.now(); + while (Date.now() - startDate <= options.timeout * 1000) { + if (downloadFilePath !== '') { + break; + } + await new Promise(resolve => setTimeout(resolve, 500)); + } + + if (downloadFilePath === '') { + throw new Error(`Timeout exceeded while waiting for download to end`); + } + + await watcher.close(); + + let filePath = downloadFilePath; + if (options.filePath) { + fs.renameSync(downloadFilePath, options.filePath); + filePath = options.filePath; + fs.rmdirSync(path.dirname(downloadFilePath)); + } + + return { filePath, fileName: path.basename(downloadFilePath) }; + } + addPageListeners(page: any) { page.on('error', this.logError); page.on('pageerror', this.logPageError); diff --git a/src/browser/clustered.ts b/src/browser/clustered.ts index 993a08e3..acac60da 100644 --- a/src/browser/clustered.ts +++ b/src/browser/clustered.ts @@ -1,10 +1,22 @@ import { Cluster } from 'puppeteer-cluster'; -import { Browser, RenderResponse, RenderOptions } from './browser'; +import { Browser, RenderResponse, RenderOptions, RenderCSVOptions, RenderCSVResponse } from './browser'; import { Logger } from '../logger'; import { RenderingConfig, ClusteringConfig } from '../config'; +enum RenderType { + CSV = 'csv', + PNG = 'png', +} + +interface ClusterOptions { + options: RenderOptions | RenderCSVOptions; + renderType: RenderType; +} + +type ClusterResponse = RenderResponse | RenderCSVResponse; + export class ClusteredBrowser extends Browser { - cluster: Cluster; + cluster: Cluster; clusteringConfig: ClusteringConfig; concurrency: number; @@ -27,14 +39,20 @@ export class ClusteredBrowser extends Browser { puppeteerOptions: launcherOptions, }); await this.cluster.task(async ({ page, data }) => { - if (data.timezone) { + if (data.options.timezone) { // set timezone - await page.emulateTimezone(data.timezone); + await page.emulateTimezone(data.options.timezone); } try { this.addPageListeners(page); - return await this.takeScreenshot(page, data); + switch (data.renderType) { + case RenderType.CSV: + return await this.exportCSV(page, data.options); + case RenderType.PNG: + default: + return await this.takeScreenshot(page, data.options); + } } finally { this.removePageListeners(page); } @@ -42,7 +60,12 @@ export class ClusteredBrowser extends Browser { } async render(options: RenderOptions): Promise { - this.validateOptions(options); - return await this.cluster.execute(options); + this.validateImageOptions(options); + return this.cluster.execute({ options, renderType: RenderType.PNG }); + } + + async renderCSV(options: RenderCSVOptions): Promise { + this.validateRenderOptions(options); + return this.cluster.execute({ options, renderType: RenderType.CSV }); } } diff --git a/src/browser/reusable.ts b/src/browser/reusable.ts index 2c835fde..f4b80c4f 100644 --- a/src/browser/reusable.ts +++ b/src/browser/reusable.ts @@ -1,5 +1,5 @@ import * as puppeteer from 'puppeteer'; -import { Browser, RenderResponse, RenderOptions } from './browser'; +import { Browser, RenderResponse, RenderOptions, RenderCSVResponse, RenderCSVOptions } from './browser'; import { Logger } from '../logger'; import { RenderingConfig } from '../config'; @@ -20,7 +20,7 @@ export class ReusableBrowser extends Browser { let page: puppeteer.Page | undefined; try { - this.validateOptions(options); + this.validateImageOptions(options); context = await this.browser.createIncognitoBrowserContext(); page = await context.newPage(); @@ -42,4 +42,32 @@ export class ReusableBrowser extends Browser { } } } + + async renderCSV(options: RenderCSVOptions): Promise { + let context: puppeteer.BrowserContext | undefined; + let page: puppeteer.Page | undefined; + + try { + this.validateRenderOptions(options); + context = await this.browser.createIncognitoBrowserContext(); + page = await context.newPage(); + + if (options.timezone) { + // set timezone + await page.emulateTimezone(options.timezone); + } + + this.addPageListeners(page); + + return await this.exportCSV(page, options); + } finally { + if (page) { + this.removePageListeners(page); + await page.close(); + } + if (context) { + await context.close(); + } + } + } } diff --git a/src/plugin/v2/grpc_plugin.ts b/src/plugin/v2/grpc_plugin.ts index b2e28679..497ca77e 100644 --- a/src/plugin/v2/grpc_plugin.ts +++ b/src/plugin/v2/grpc_plugin.ts @@ -5,10 +5,12 @@ import { GrpcPlugin } from '../../node-plugin'; import { Logger } from '../../logger'; import { PluginConfig } from '../../config'; import { createBrowser, Browser } from '../../browser'; -import { RenderOptions, HTTPHeaders } from '../../browser/browser'; +import { RenderOptions, RenderCSVOptions, HTTPHeaders } from '../../browser/browser'; import { RenderRequest, RenderResponse, + RenderCSVRequest, + RenderCSVResponse, CheckHealthRequest, CheckHealthResponse, CollectMetricsRequest, @@ -122,6 +124,46 @@ class PluginGRPCServer { callback(null, { error: errStr }); } + async renderCsv(call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData) { + const req = call.request; + const headers: HTTPHeaders = {}; + + if (!req) { + throw new Error('Request cannot be null'); + } + + if (req.headers) { + for (const key in req.headers) { + if (req.headers.hasOwnProperty(key)) { + const h = req.headers[key]; + headers[key] = h.values.join(';'); + } + } + } + + const options: RenderCSVOptions = { + url: req.url, + filePath: req.filePath, + timeout: req.timeout, + renderKey: req.renderKey, + domain: req.domain, + timezone: req.timezone, + headers: headers, + }; + + this.log.debug('Render request received', 'url', options.url); + let errStr = ''; + let fileName = ''; + try { + const result = await this.browser.renderCSV(options); + fileName = result.fileName || ''; + } catch (err) { + this.log.error('Render request failed', 'url', options.url, 'error', err.toString()); + errStr = err.toString(); + } + callback(null, { error: errStr, fileName }); + } + async checkHealth(_: grpc.ServerUnaryCall, callback: grpc.sendUnaryData) { const jsonDetails = Buffer.from( JSON.stringify({ diff --git a/src/plugin/v2/types.ts b/src/plugin/v2/types.ts index 6366c625..42e34acb 100644 --- a/src/plugin/v2/types.ts +++ b/src/plugin/v2/types.ts @@ -21,6 +21,23 @@ export interface RenderResponse { error?: any; } +export interface RenderCSVRequest { + url: string; + filePath: string; + renderKey: string; + domain: string; + timeout: number; + timezone: string; + headers: { + [header: string]: StringList; + }; +} + +export interface RenderCSVResponse { + error?: any; + fileName?: string; +} + export interface CollectMetricsRequest {} export interface MetricsPayload { diff --git a/src/service/http-server.ts b/src/service/http-server.ts index d5dc74f5..005d61d6 100644 --- a/src/service/http-server.ts +++ b/src/service/http-server.ts @@ -1,4 +1,5 @@ import * as fs from 'fs'; +import * as path from 'path'; import * as net from 'net'; import express = require('express'); import * as boom from '@hapi/boom'; @@ -8,7 +9,7 @@ import { Logger } from '../logger'; import { Browser } from '../browser'; import { ServiceConfig } from '../config'; import { metricsMiddleware } from './metrics_middleware'; -import { RenderOptions, HTTPHeaders } from '../browser/browser'; +import { RenderOptions, RenderCSVOptions, HTTPHeaders } from '../browser/browser'; export interface RenderRequest { url: string; @@ -23,6 +24,16 @@ export interface RenderRequest { encoding: string; } +export interface RenderCSVRequest { + url: string; + filePath: string; + renderKey: string; + domain: string; + timeout: number; + timezone: string; + encoding: string; +} + export class HttpServer { app: express.Express; @@ -52,6 +63,7 @@ export class HttpServer { }); this.app.get('/render', asyncMiddleware(this.render)); + this.app.get('/render/csv', asyncMiddleware(this.renderCSV)); this.app.use((err, req, res, next) => { if (err.stack) { this.log.error('Request failed', 'url', req.url, 'stack', err.stack); @@ -123,6 +135,52 @@ export class HttpServer { this.log.debug('Connection closed', 'url', options.url, 'error', err); }); const result = await this.browser.render(options); + + res.sendFile(result.filePath, err => { + if (err) { + next(err); + } else { + try { + this.log.debug('Deleting temporary file', 'file', result.filePath); + fs.unlinkSync(result.filePath); + } catch (e) { + this.log.error('Failed to delete temporary file', 'file', result.filePath); + } + } + }); + }; + + renderCSV = async (req: express.Request, res: express.Response, next: express.NextFunction) => { + if (!req.query.url) { + throw boom.badRequest('Missing url parameter'); + } + + const headers: HTTPHeaders = {}; + + if (req.headers['Accept-Language']) { + headers['Accept-Language'] = (req.headers['Accept-Language'] as string[]).join(';'); + } + + const options: RenderCSVOptions = { + url: req.query.url, + filePath: req.query.filePath, + timeout: req.query.timeout, + renderKey: req.query.renderKey, + domain: req.query.domain, + timezone: req.query.timezone, + encoding: req.query.encoding, + headers: headers, + }; + + this.log.debug('Render request received', 'url', options.url); + req.on('close', err => { + this.log.debug('Connection closed', 'url', options.url, 'error', err); + }); + const result = await this.browser.renderCSV(options); + + if (result.fileName) { + res.setHeader('Content-Disposition', `attachment; filename="${result.fileName}"`); + } res.sendFile(result.filePath, err => { if (err) { next(err); @@ -130,6 +188,9 @@ export class HttpServer { try { this.log.debug('Deleting temporary file', 'file', result.filePath); fs.unlinkSync(result.filePath); + if (!options.filePath) { + fs.rmdirSync(path.dirname(result.filePath)); + } } catch (e) { this.log.error('Failed to delete temporary file', 'file', result.filePath); } diff --git a/yarn.lock b/yarn.lock index a89a1a1d..6c1db3fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,6 +373,14 @@ any-observable@^0.3.0: resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== +anymatch@~3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" + integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + aproba@^1.0.3: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -442,6 +450,11 @@ basic-auth@~2.0.1: dependencies: safe-buffer "5.1.2" +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + bintrees@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" @@ -480,7 +493,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.1: +braces@^3.0.1, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -557,6 +570,21 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chokidar@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" + integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.5.0" + optionalDependencies: + fsevents "~2.3.1" + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -1281,6 +1309,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@~2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -1327,7 +1360,7 @@ github-from-package@0.0.0: resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" integrity sha1-l/tdlr/eiXMxPyDoKI75oWf6ZM4= -glob-parent@^5.0.0, glob-parent@^5.1.0: +glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -1570,6 +1603,13 @@ is-arrayish@^0.3.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-core-module@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.3.0.tgz#d341652e3408bca69c4671b79a0954a3d349f887" @@ -1604,7 +1644,7 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-glob@^4.0.0, is-glob@^4.0.1: +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -2046,7 +2086,7 @@ noop-logger@^0.1.1: resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -2255,7 +2295,7 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= -picomatch@^2.2.1, picomatch@^2.2.3: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.3.tgz#465547f359ccc206d3c48e46a1bcb89bf7ee619d" integrity sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg== @@ -2492,6 +2532,13 @@ readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readdirp@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e" + integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ== + dependencies: + picomatch "^2.2.1" + regexpp@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"