Kaynağa Gözat

Improve error handling, logging, pending downloads flow and startup logic (drop files with missing cache data)

Leszek Wiesner 3 yıl önce
ebeveyn
işleme
4171f5887f

+ 3 - 1
distributor-node/package.json

@@ -41,7 +41,9 @@
     "inquirer": "^8.1.2",
     "multihashes": "^4.0.3",
     "blake3": "^2.1.4",
-    "js-image-generator": "^1.0.3"
+    "js-image-generator": "^1.0.3",
+    "url-join": "^4.0.1",
+    "@types/url-join": "^4.0.1"
   },
   "devDependencies": {
     "@graphql-codegen/cli": "^1.21.4",

+ 11 - 15
distributor-node/src/services/cache/StateCacheService.ts

@@ -1,9 +1,10 @@
 import { Logger } from 'winston'
-import { PendingDownloadData, PendingDownloadStatus, ReadonlyConfig, StorageNodeDownloadResponse } from '../../types'
+import { ReadonlyConfig } from '../../types'
 import { LoggingService } from '../logging'
 import _ from 'lodash'
 import fs from 'fs'
 import NodeCache from 'node-cache'
+import { PendingDownload } from '../networking/PendingDownload'
 
 // LRU-SP cache parameters
 // Since size is in KB, these parameters should be enough for grouping objects of size up to 2^24 KB = 16 GB
@@ -28,7 +29,7 @@ export class StateCacheService {
   private cacheFilePath: string
 
   private memoryState = {
-    pendingDownloadsByObjectId: new Map<string, PendingDownloadData>(),
+    pendingDownloadsByObjectId: new Map<string, PendingDownload>(),
     storageNodeEndpointDataByEndpoint: new Map<string, StorageNodeEndpointData>(),
     groupNumberByObjectId: new Map<string, number>(),
     dataObjectSourceByObjectId: new NodeCache({
@@ -146,17 +147,8 @@ export class StateCacheService {
     return bestCandidate
   }
 
-  public newPendingDownload(
-    objectId: string,
-    objectSize: number,
-    promise: Promise<StorageNodeDownloadResponse>
-  ): PendingDownloadData {
-    const pendingDownload: PendingDownloadData = {
-      status: PendingDownloadStatus.Waiting,
-      objectSize,
-      promise,
-    }
-    this.memoryState.pendingDownloadsByObjectId.set(objectId, pendingDownload)
+  public addPendingDownload(pendingDownload: PendingDownload): PendingDownload {
+    this.memoryState.pendingDownloadsByObjectId.set(pendingDownload.getObjectId(), pendingDownload)
     return pendingDownload
   }
 
@@ -164,12 +156,16 @@ export class StateCacheService {
     return this.memoryState.pendingDownloadsByObjectId.size
   }
 
-  public getPendingDownload(objectId: string): PendingDownloadData | undefined {
+  public getPendingDownload(objectId: string): PendingDownload | undefined {
     return this.memoryState.pendingDownloadsByObjectId.get(objectId)
   }
 
   public dropPendingDownload(objectId: string): void {
-    this.memoryState.pendingDownloadsByObjectId.delete(objectId)
+    const pendingDownload = this.memoryState.pendingDownloadsByObjectId.get(objectId)
+    if (pendingDownload) {
+      pendingDownload.cleanup()
+      this.memoryState.pendingDownloadsByObjectId.delete(objectId)
+    }
   }
 
   public dropById(objectId: string): void {

+ 18 - 13
distributor-node/src/services/content/ContentService.ts

@@ -91,6 +91,12 @@ export class ContentService {
         continue
       }
 
+      // Drop files that are missing in the cache
+      if (!this.stateCache.peekContent(objectId)) {
+        this.drop(objectId, 'Missing cache data')
+        continue
+      }
+
       // Compare file size to expected one
       const { size: dataObjectSize } = dataObject
       if (fileSize !== dataObjectSize) {
@@ -222,7 +228,17 @@ export class ContentService {
       let bytesReceived = 0
       const hash = new ContentHash()
 
+      const onData = (chunk: Buffer) => {
+        bytesReceived += chunk.length
+        hash.update(chunk)
+
+        if (bytesReceived > expectedSize) {
+          dataStream.destroy(new Error('Unexpected content size: Too much data received from source!'))
+        }
+      }
+
       pipeline(dataStream, fileStream, async (err) => {
+        dataStream.off('data', onData)
         const { bytesWritten } = fileStream
         const finalHash = hash.digest()
         const logMetadata = {
@@ -267,18 +283,7 @@ export class ContentService {
         // Note: The promise is resolved on "ready" event, since that's what's awaited in the current flow
         resolve()
       })
-
-      dataStream.on('data', (chunk) => {
-        if (dataStream.destroyed) {
-          return
-        }
-        bytesReceived += chunk.length
-        hash.update(chunk)
-
-        if (bytesReceived > expectedSize) {
-          dataStream.destroy(new Error('Unexpected content size: Too much data received from source!'))
-        }
-      })
+      dataStream.on('data', onData)
     })
   }
 
@@ -290,7 +295,7 @@ export class ContentService {
     }
 
     if (pendingDownload) {
-      return { type: ObjectStatusType.PendingDownload, pendingDownloadData: pendingDownload }
+      return { type: ObjectStatusType.PendingDownload, pendingDownload }
     }
 
     const objectInfo = await this.networking.dataObjectInfo(objectId)

+ 4 - 0
distributor-node/src/services/httpApi/HttpApiService.ts

@@ -25,6 +25,8 @@ export class HttpApiService {
     handler: (req: express.Request<T>, res: express.Response, next: express.NextFunction) => Promise<void>
   ) {
     return async (req: express.Request<T>, res: express.Response, next: express.NextFunction) => {
+      // Fix for express-winston in order to also log prematurely closed requests
+      res.on('close', () => res.end())
       try {
         await handler(req, res, next)
       } catch (err) {
@@ -78,6 +80,8 @@ export class HttpApiService {
       expressWinston.errorLogger({
         winstonInstance: this.logger,
         level: 'error',
+        metaField: null,
+        exceptionToMeta: (err) => ({ err }),
       })
     )
 

+ 40 - 25
distributor-node/src/services/httpApi/controllers/public.ts

@@ -8,6 +8,8 @@ import { LoggingService } from '../../logging'
 import { ContentService, DEFAULT_CONTENT_TYPE } from '../../content/ContentService'
 import proxy from 'express-http-proxy'
 import { DataObjectData, ObjectStatusType, ReadonlyConfig } from '../../../types'
+import { PendingDownloadStatusDownloading, PendingDownloadStatusType } from '../../networking/PendingDownload'
+import urljoin from 'url-join'
 
 const CACHED_MAX_AGE = 31536000
 const PENDING_MAX_AGE = 180
@@ -37,6 +39,32 @@ export class PublicApiController {
     return { type, message }
   }
 
+  private async proxyAssetRequest(
+    req: express.Request<AssetRouteParams>,
+    res: express.Response,
+    next: express.NextFunction,
+    objectId: string,
+    sourceApiEndpoint: string
+  ) {
+    const sourceObjectUrl = new URL(urljoin(sourceApiEndpoint, `files/${objectId}`))
+    res.setHeader('x-data-source', 'external')
+    this.logger.verbose(`Forwarding request to ${sourceObjectUrl.toString()}`, {
+      objectId,
+      sourceUrl: sourceObjectUrl.href,
+    })
+    return proxy(sourceObjectUrl.origin, {
+      proxyReqPathResolver: () => sourceObjectUrl.pathname,
+      proxyErrorHandler: (err, res, next) => {
+        this.logger.error(`Proxy request to ${sourceObjectUrl} failed!`, {
+          objectId,
+          sourceObjectUrl: sourceObjectUrl.href,
+        })
+        this.stateCache.dropCachedDataObjectSource(objectId, sourceApiEndpoint)
+        next(err)
+      },
+    })(req, res, next)
+  }
+
   private async serveMissingAsset(
     req: express.Request<AssetRouteParams>,
     res: express.Response,
@@ -52,22 +80,9 @@ export class PublicApiController {
         size,
         maxCachedItemSize,
       })
-      const sourceRootApiEndpoint = await this.networking.getDataObjectDownloadSource(objectData)
-      const sourceUrl = new URL(`files/${objectId}`, `${sourceRootApiEndpoint}/`)
+      const source = await this.networking.getDataObjectDownloadSource(objectData)
       res.setHeader('x-cache', 'miss')
-      res.setHeader('x-data-source', 'external')
-      this.logger.info(`Proxying request to ${sourceUrl.toString()}`, { objectId, sourceUrl: sourceUrl.toString() })
-      return proxy(sourceUrl.origin, {
-        proxyReqPathResolver: () => sourceUrl.pathname,
-        proxyErrorHandler: (err, res, next) => {
-          this.logger.error(`Proxy request to ${sourceUrl} failed!`, {
-            objectId,
-            sourceUrl: sourceUrl.toString(),
-          })
-          this.stateCache.dropCachedDataObjectSource(objectId, sourceRootApiEndpoint)
-          next(err)
-        },
-      })(req, res, next)
+      return this.proxyAssetRequest(req, res, next, objectId, source)
     }
 
     const downloadResponse = await this.networking.downloadDataObject({ objectData })
@@ -88,6 +103,7 @@ export class PublicApiController {
     next: express.NextFunction,
     objectId: string
   ): void {
+    this.logger.verbose('Serving object from filesystem', { objectId })
     // TODO: Limit the number of times useContent is trigerred for similar requests?
     // (for example: same ip, 3 different request within a minute = 1 request)
     this.stateCache.useContent(objectId)
@@ -131,12 +147,13 @@ export class PublicApiController {
     if (!pendingDownload) {
       throw new Error('Trying to serve pending download asset that is not pending download!')
     }
+    const status = pendingDownload.getStatus().type
+    this.logger.verbose('Serving object in pending download state', { objectId, status })
 
-    const { promise, objectSize } = pendingDownload
-    const response = await promise
-    const source = new URL(response.config.url || '')
-    const contentType = response.headers['content-type'] || DEFAULT_CONTENT_TYPE
-    res.setHeader('content-type', contentType)
+    await pendingDownload.untilStatus(PendingDownloadStatusType.Downloading)
+    const objectSize = pendingDownload.getObjectSize()
+    const { source, contentType } = pendingDownload.getStatus() as PendingDownloadStatusDownloading
+    res.setHeader('content-type', contentType || DEFAULT_CONTENT_TYPE)
     // Allow caching pendingDownload reponse only for very short period of time and requite revalidation,
     // since the data coming from the source may not be valid
     res.setHeader('cache-control', `max-age=${PENDING_MAX_AGE}, must-revalidate`)
@@ -155,9 +172,7 @@ export class PublicApiController {
     }
 
     // Range doesn't start from the beginning of the content or the file was not found - froward request to source storage node
-    this.logger.verbose(`Forwarding request to ${source.href}`, { source: source.href })
-    res.setHeader('x-data-source', 'external')
-    return proxy(source.origin, { proxyReqPathResolver: () => source.pathname })(req, res, next)
+    return this.proxyAssetRequest(req, res, next, objectId, source)
   }
 
   private async servePendingDownloadAssetFromFile(
@@ -183,7 +198,7 @@ export class PublicApiController {
     stream.pipe(res)
     req.on('close', () => {
       stream.destroy()
-      res.destroy()
+      res.end()
     })
   }
 
@@ -207,7 +222,7 @@ export class PublicApiController {
         res.status(200)
         res.setHeader('x-cache', 'pending')
         res.setHeader('cache-control', `max-age=${PENDING_MAX_AGE}, must-revalidate`)
-        res.setHeader('content-length', objectStatus.pendingDownloadData.objectSize)
+        res.setHeader('content-length', objectStatus.pendingDownload.getObjectSize())
         break
       case ObjectStatusType.NotFound:
         res.status(404)

+ 14 - 1
distributor-node/src/services/logging/LoggingService.ts

@@ -22,7 +22,8 @@ const pausedLogs = new NodeCache({
 })
 
 // Pause log for a specified time period
-const pauseFormat: (opts: { id: string }) => Format = winston.format((info, opts: { id: string }) => {
+type PauseFormatOpts = { id: string }
+const pauseFormat: (opts: PauseFormatOpts) => Format = winston.format((info, opts: PauseFormatOpts) => {
   if (info['@pauseFor']) {
     const messageHash = blake2AsHex(`${opts.id}:${info.level}:${info.message}`)
     if (!pausedLogs.has(messageHash)) {
@@ -37,8 +38,20 @@ const pauseFormat: (opts: { id: string }) => Format = winston.format((info, opts
   return info
 })
 
+// Error format applied to specific log meta field
+type ErrorFormatOpts = { filedName: string }
+const errorFormat: (opts: ErrorFormatOpts) => Format = winston.format((info, opts: ErrorFormatOpts) => {
+  if (!info[opts.filedName]) {
+    return info
+  }
+  const formatter = winston.format.errors({ stack: true })
+  info[opts.filedName] = formatter.transform(info[opts.filedName], formatter.options)
+  return info
+})
+
 const cliFormat = winston.format.combine(
   winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
+  errorFormat({ filedName: 'err' }),
   winston.format.metadata({ fillExcept: ['label', 'level', 'timestamp', 'message'] }),
   winston.format.colorize({ all: true }),
   winston.format.printf(

+ 32 - 39
distributor-node/src/services/networking/NetworkingService.ts

@@ -13,14 +13,13 @@ import {
   DataObjectInfo,
   StorageNodeDownloadResponse,
   DownloadData,
-  PendingDownloadData,
-  PendingDownloadStatus,
 } from '../../types'
 import queue from 'queue'
 import { DistributionBucketOperatorStatus } from './query-node/generated/schema'
 import http from 'http'
 import https from 'https'
 import { parseAxiosError } from '../parsers/errors'
+import { PendingDownload, PendingDownloadStatusType } from './PendingDownload'
 
 // Concurrency limits
 export const MAX_CONCURRENT_AVAILABILITY_CHECKS_PER_OBJECT = 10
@@ -61,6 +60,9 @@ export class NetworkingService {
       }
     )
     this.downloadQueue = queue({ concurrency: config.limits.maxConcurrentStorageNodeDownloads, autostart: true })
+    this.downloadQueue.on('error', (err) => {
+      this.logger.error('Data object download failed', { err })
+    })
   }
 
   private validateNodeEndpoint(endpoint: string): void {
@@ -182,6 +184,13 @@ export class NetworkingService {
       })
     })
 
+    availabilityQueue.on('error', () => {
+      /*
+      Do nothing.
+      The handler is needed to avoid unhandled promise rejection
+      */
+    })
+
     return availabilityQueue
   }
 
@@ -233,13 +242,6 @@ export class NetworkingService {
         return resolve(endpoint)
       })
 
-      availabilityQueue.on('error', () => {
-        /*
-        Do nothing.
-        The handler is needed to avoid unhandled promise rejection
-        */
-      })
-
       availabilityQueue.on('end', () => {
         return reject(new Error('Failed to find data object download source'))
       })
@@ -247,7 +249,7 @@ export class NetworkingService {
   }
 
   private downloadJob(
-    pendingDownload: PendingDownloadData,
+    pendingDownload: PendingDownload,
     downloadData: DownloadData,
     onSourceFound: (response: StorageNodeDownloadResponse) => void,
     onError: (error: Error) => void,
@@ -258,7 +260,7 @@ export class NetworkingService {
       startAt,
     } = downloadData
 
-    pendingDownload.status = PendingDownloadStatus.LookingForSource
+    pendingDownload.setStatus({ type: PendingDownloadStatusType.LookingForSource })
 
     return new Promise<void>((resolve, reject) => {
       // Handlers:
@@ -268,9 +270,13 @@ export class NetworkingService {
         reject(new Error(message))
       }
 
-      const sourceFound = (response: StorageNodeDownloadResponse) => {
-        this.logger.info('Download source chosen', { objectId, source: response.config.url })
-        pendingDownload.status = PendingDownloadStatus.Downloading
+      const sourceFound = (endpoint: string, response: StorageNodeDownloadResponse) => {
+        this.logger.info('Download source chosen', { objectId, source: endpoint })
+        pendingDownload.setStatus({
+          type: PendingDownloadStatusType.Downloading,
+          source: endpoint,
+          contentType: response.headers['content-type'],
+        })
         onSourceFound(response)
       }
 
@@ -291,7 +297,7 @@ export class NetworkingService {
         })),
       })
       if (!storageEndpoints.length) {
-        return fail('No storage endpoints available to download the data object from')
+        return fail(`No storage endpoints available to download the data object: ${objectId}`)
       }
 
       const availabilityQueue = this.createDataObjectAvailabilityCheckQueue(objectId, storageEndpoints)
@@ -302,21 +308,14 @@ export class NetworkingService {
         const job = async () => {
           const api = new StorageNodeApi(endpoint, this.logging, this.config)
           const response = await api.downloadObject(objectId, startAt)
-          return response
+          return [endpoint, response]
         }
         objectDownloadQueue.push(job)
       })
 
-      availabilityQueue.on('error', () => {
-        /*
-        Do nothing.
-        The handler is needed to avoid unhandled promise rejection
-        */
-      })
-
       availabilityQueue.on('end', () => {
         if (!objectDownloadQueue.length) {
-          fail('Failed to download the object from any availablable storage provider')
+          fail(`Failed to download object ${objectId} from any availablable storage provider`)
         }
       })
 
@@ -328,15 +327,15 @@ export class NetworkingService {
         if (availabilityQueue.length) {
           availabilityQueue.start()
         } else {
-          fail('Failed to download the object from any availablable storage provider')
+          fail(`Failed to download object ${objectId} from any availablable storage provider`)
         }
       })
 
-      objectDownloadQueue.on('success', (response: StorageNodeDownloadResponse) => {
+      objectDownloadQueue.on('success', ([endpoint, response]: [string, StorageNodeDownloadResponse]) => {
         availabilityQueue.removeAllListeners().end()
         objectDownloadQueue.removeAllListeners().end()
         response.data.on('close', finish).on('error', finish).on('end', finish)
-        sourceFound(response)
+        sourceFound(endpoint, response)
       })
     })
   }
@@ -345,23 +344,17 @@ export class NetworkingService {
     const {
       objectData: { objectId, size },
     } = downloadData
-
     if (this.stateCache.getPendingDownload(objectId)) {
       // Already downloading
       return null
     }
-
-    let resolveDownload: (response: StorageNodeDownloadResponse) => void, rejectDownload: (err: Error) => void
-    const downloadPromise = new Promise<StorageNodeDownloadResponse>((resolve, reject) => {
-      resolveDownload = resolve
-      rejectDownload = reject
+    const pendingDownload = this.stateCache.addPendingDownload(new PendingDownload(objectId, size))
+    return new Promise<StorageNodeDownloadResponse>((resolve, reject) => {
+      const onSourceFound = resolve
+      const onError = reject
+      // Queue the download
+      this.downloadQueue.push(() => this.downloadJob(pendingDownload, downloadData, onSourceFound, onError))
     })
-
-    // Queue the download
-    const pendingDownload = this.stateCache.newPendingDownload(objectId, size, downloadPromise)
-    this.downloadQueue.push(() => this.downloadJob(pendingDownload, downloadData, resolveDownload, rejectDownload))
-
-    return downloadPromise
   }
 
   async fetchSupportedDataObjects(): Promise<Map<string, DataObjectData>> {

+ 84 - 0
distributor-node/src/services/networking/PendingDownload.ts

@@ -0,0 +1,84 @@
+export enum PendingDownloadStatusType {
+  Waiting = 'Waiting',
+  LookingForSource = 'LookingForSource',
+  Downloading = 'Downloading',
+}
+
+export type PendingDownloadStatusWaiting = {
+  type: PendingDownloadStatusType.Waiting
+}
+
+export type PendingDownloadStatusLookingForSource = {
+  type: PendingDownloadStatusType.LookingForSource
+}
+
+export type PendingDownloadStatusDownloading = {
+  type: PendingDownloadStatusType.Downloading
+  source: string
+  contentType?: string
+}
+
+export type PendingDownloadStatus =
+  | PendingDownloadStatusWaiting
+  | PendingDownloadStatusLookingForSource
+  | PendingDownloadStatusDownloading
+
+export const STATUS_ORDER = [
+  PendingDownloadStatusType.Waiting,
+  PendingDownloadStatusType.LookingForSource,
+  PendingDownloadStatusType.Downloading,
+] as const
+
+export class PendingDownload {
+  private objectId: string
+  private objectSize: number
+  private status: PendingDownloadStatus = { type: PendingDownloadStatusType.Waiting }
+  private statusHandlers: Map<PendingDownloadStatusType, (() => void)[]> = new Map()
+  private cleanupHandlers: (() => void)[] = []
+
+  constructor(objectId: string, objectSize: number) {
+    this.objectId = objectId
+    this.objectSize = objectSize
+  }
+
+  setStatus(status: PendingDownloadStatus): void {
+    this.status = status
+    const handlers = this.statusHandlers.get(status.type) || []
+    handlers.forEach((handler) => handler())
+  }
+
+  getStatus(): PendingDownloadStatus {
+    return this.status
+  }
+
+  getObjectId(): string {
+    return this.objectId
+  }
+
+  getObjectSize(): number {
+    return this.objectSize
+  }
+
+  private registerStatusHandler(statusType: PendingDownloadStatusType, handler: () => void) {
+    const currentHandlers = this.statusHandlers.get(statusType) || []
+    this.statusHandlers.set(statusType, [...currentHandlers, handler])
+  }
+
+  private registerCleanupHandler(handler: () => void) {
+    this.cleanupHandlers.push(handler)
+  }
+
+  untilStatus<T extends PendingDownloadStatusType>(statusType: T): Promise<void> {
+    return new Promise((resolve, reject) => {
+      if (STATUS_ORDER.indexOf(this.status.type) >= STATUS_ORDER.indexOf(statusType)) {
+        return resolve()
+      }
+      this.registerStatusHandler(statusType, () => resolve())
+      this.registerCleanupHandler(() => reject(new Error(`Could not download object ${this.objectId} from any source`)))
+    })
+  }
+
+  cleanup(): void {
+    this.cleanupHandlers.forEach((handler) => handler())
+  }
+}

+ 2 - 13
distributor-node/src/types/content.ts

@@ -1,5 +1,6 @@
 import { AxiosResponse } from 'axios'
 import { Readable } from 'stream'
+import { PendingDownload } from '../services/networking/PendingDownload'
 
 export type StorageNodeEndpointData = {
   bucketId: string
@@ -30,18 +31,6 @@ export type DataObjectInfo = {
   data?: DataObjectData
 }
 
-export enum PendingDownloadStatus {
-  Waiting = 'Waiting',
-  LookingForSource = 'LookingForSource',
-  Downloading = 'Downloading',
-}
-
-export type PendingDownloadData = {
-  objectSize: number
-  status: PendingDownloadStatus
-  promise: Promise<StorageNodeDownloadResponse>
-}
-
 export enum ObjectStatusType {
   Available = 'Available',
   PendingDownload = 'PendingDownload',
@@ -57,7 +46,7 @@ export type ObjectStatusAvailable = {
 
 export type ObjectStatusPendingDownload = {
   type: ObjectStatusType.PendingDownload
-  pendingDownloadData: PendingDownloadData
+  pendingDownload: PendingDownload
 }
 
 export type ObjectStatusNotFound = {

+ 10 - 0
yarn.lock

@@ -6368,6 +6368,11 @@
   resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
   integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
 
+"@types/url-join@^4.0.1":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-4.0.1.tgz#4989c97f969464647a8586c7252d97b449cdc045"
+  integrity sha512-wDXw9LEEUHyV+7UWy7U315nrJGJ7p1BzaCxDpEoLr789Dk1WDVMMlf3iBfbG2F8NdWnYyFbtTxUn2ZNbm1Q4LQ==
+
 "@types/uuid@^3.4.4":
   version "3.4.9"
   resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.9.tgz#fcf01997bbc9f7c09ae5f91383af076d466594e1"
@@ -30980,6 +30985,11 @@ urix@^0.1.0:
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
   integrity "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg=="
 
+url-join@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7"
+  integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==
+
 url-loader@^1.0.1:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-1.1.2.tgz#b971d191b83af693c5e3fea4064be9e1f2d7f8d8"