From 04786fab504467df5e3511f2de79659332a729e9 Mon Sep 17 00:00:00 2001 From: Michal Cieslar Date: Sat, 11 May 2024 14:43:59 +0200 Subject: [PATCH] add support for multiple files upload --- apps/backend/package.json | 1 + .../src/common/types/http/httpRequest.ts | 6 +- apps/backend/src/core/httpRouter.ts | 35 ++++++--- apps/backend/src/core/httpServer.ts | 2 +- .../resourceHttpController.ts | 40 +++++----- .../schemas/uploadResourceSchema.ts | 12 +-- .../uploadResourceCommandHandler.ts | 13 ---- .../uploadResourceCommandHandlerImpl.ts | 66 ----------------- .../uploadResourcesCommandHandler.ts | 10 +++ .../uploadResourcesCommandHandlerImpl.ts | 73 +++++++++++++++++++ .../modules/resourceModule/resourceModule.ts | 12 +-- .../src/modules/resourceModule/symbols.ts | 2 +- common/contracts/src/index.ts | 2 +- .../src/schemas/resource/uploadResource.ts | 3 - .../src/schemas/resource/uploadResources.ts | 3 + package-lock.json | 15 ++++ 16 files changed, 163 insertions(+), 132 deletions(-) delete mode 100644 apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.ts delete mode 100644 apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandlerImpl.ts create mode 100644 apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.ts create mode 100644 apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandlerImpl.ts delete mode 100644 common/contracts/src/schemas/resource/uploadResource.ts create mode 100644 common/contracts/src/schemas/resource/uploadResources.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 21b5731..ea93f99 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -36,6 +36,7 @@ "inversify": "6.0.2", "jsonwebtoken": "9.0.2", "knex": "3.1.0", + "mime": "^4.0.3", "node-fetch": "3.3.2", "pino": "9.0.0", "sharp": "0.33.3", diff --git a/apps/backend/src/common/types/http/httpRequest.ts b/apps/backend/src/common/types/http/httpRequest.ts index 1c8c56e..b9f4ede 100644 --- a/apps/backend/src/common/types/http/httpRequest.ts +++ b/apps/backend/src/common/types/http/httpRequest.ts @@ -1,11 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { type Readable } from 'node:stream'; - export interface AttachedFile { readonly name: string; readonly type: string; - readonly data: Readable; + readonly filePath: string; } export interface HttpRequest { @@ -13,5 +11,5 @@ export interface HttpRequest { readonly queryParams: QueryParams; readonly pathParams: PathParams; readonly headers: Record; - readonly file?: AttachedFile | undefined; + readonly files?: AttachedFile[] | undefined; } diff --git a/apps/backend/src/core/httpRouter.ts b/apps/backend/src/core/httpRouter.ts index e02991d..b36e813 100644 --- a/apps/backend/src/core/httpRouter.ts +++ b/apps/backend/src/core/httpRouter.ts @@ -2,6 +2,9 @@ import { TypeClone } from '@sinclair/typebox'; import { type FastifyInstance, type FastifyReply, type FastifyRequest, type FastifySchema } from 'fastify'; +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream'; +import { promisify } from 'node:util'; import { coreSymbols } from './symbols.js'; import { ApplicationError } from '../common/errors/base/applicationError.js'; @@ -20,6 +23,8 @@ import { type LoggerService } from '../libs/logger/services/loggerService/logger import { ForbiddenAccessError } from '../modules/authModule/application/errors/forbiddenAccessError.js'; import { UnauthorizedAccessError } from '../modules/authModule/application/errors/unathorizedAccessError.js'; +const streamPipeline = promisify(pipeline); + export interface RegisterControllersPayload { controllers: HttpController[]; } @@ -82,17 +87,27 @@ export class HttpRouter { headers: fastifyRequest.headers, }); - let attachedFile: AttachedFile | undefined; + let attachedFiles: AttachedFile[] | undefined; if (fastifyRequest.isMultipart()) { - const file = await fastifyRequest.file(); - - if (file) { - attachedFile = { - name: file.filename, - type: file.mimetype, - data: file.file, - }; + attachedFiles = []; + + const files = fastifyRequest.files(); + + for await (const file of files) { + const { filename, mimetype, file: data } = file; + + const filePath = `/tmp/${filename}`; + + const writer = createWriteStream(filePath); + + await streamPipeline(data, writer); + + attachedFiles.push({ + name: filename, + type: mimetype, + filePath, + }); } } @@ -105,7 +120,7 @@ export class HttpRouter { pathParams: fastifyRequest.params, queryParams: fastifyRequest.query, headers: fastifyRequest.headers as Record, - file: attachedFile, + files: attachedFiles, }); fastifyReply.status(statusCode); diff --git a/apps/backend/src/core/httpServer.ts b/apps/backend/src/core/httpServer.ts index ee31945..3ee5dff 100644 --- a/apps/backend/src/core/httpServer.ts +++ b/apps/backend/src/core/httpServer.ts @@ -73,7 +73,7 @@ export class HttpServer { }); this.fastifyInstance.setSerializerCompiler(() => { - return (data) => JSON.stringify(data); + return (data): string => JSON.stringify(data); }); this.addRequestPreprocessing(); diff --git a/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/resourceHttpController.ts b/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/resourceHttpController.ts index 06743b6..3421302 100644 --- a/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/resourceHttpController.ts +++ b/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/resourceHttpController.ts @@ -41,10 +41,10 @@ import { } from './schemas/findUserBucketsSchema.js'; import { type ResourceMetadataDTO } from './schemas/resourceMetadataDTO.js'; import { - type UploadResourceResponseBodyDTO, - type UploadResourcePathParamsDTO, - uploadResourceResponseBodyDTOSchema, - uploadResourcePathParamsDTOSchema, + type UploadResourcesResponseBodyDTO, + type UploadResourcesPathParamsDTO, + uploadResourcesResponseBodyDTOSchema, + uploadResourcesPathParamsDTOSchema, } from './schemas/uploadResourceSchema.js'; import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js'; import { type HttpController } from '../../../../../common/types/http/httpController.js'; @@ -62,7 +62,7 @@ import { SecurityMode } from '../../../../../common/types/http/securityMode.js'; import { type AccessControlService } from '../../../../authModule/application/services/accessControlService/accessControlService.js'; import { type FindUserBucketsQueryHandler } from '../../../../userModule/application/queryHandlers/findUserBucketsQueryHandler/findUserBucketsQueryHandler.js'; import { type DeleteResourceCommandHandler } from '../../../application/commandHandlers/deleteResourceCommandHandler/deleteResourceCommandHandler.js'; -import { type UploadResourceCommandHandler } from '../../../application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.js'; +import { type UploadResourcesCommandHandler } from '../../../application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.js'; import { type DownloadImageQueryHandler } from '../../../application/queryHandlers/downloadImageQueryHandler/downloadImageQueryHandler.js'; import { type DownloadResourceQueryHandler } from '../../../application/queryHandlers/downloadResourceQueryHandler/downloadResourceQueryHandler.js'; import { type DownloadResourcesQueryHandler } from '../../../application/queryHandlers/downloadResourcesQueryHandler/downloadResourcesQueryHandler.js'; @@ -77,7 +77,7 @@ export class ResourceHttpController implements HttpController { private readonly deleteResourceCommandHandler: DeleteResourceCommandHandler, private readonly findResourcesMetadataQueryHandler: FindResourcesMetadataQueryHandler, private readonly downloadResourceQueryHandler: DownloadResourceQueryHandler, - private readonly uploadResourceCommandHandler: UploadResourceCommandHandler, + private readonly uploadResourceCommandHandler: UploadResourcesCommandHandler, private readonly downloadResourcesQueryHandler: DownloadResourcesQueryHandler, private readonly downloadImageQueryHandler: DownloadImageQueryHandler, private readonly downloadVideoPreviewQueryHandler: DownloadVideoPreviewQueryHandler, @@ -126,21 +126,21 @@ export class ResourceHttpController implements HttpController { new HttpRoute({ method: HttpMethodName.post, path: ':bucketName/resources', - handler: this.uploadResource.bind(this), + handler: this.uploadResources.bind(this), schema: { request: { - pathParams: uploadResourcePathParamsDTOSchema, + pathParams: uploadResourcesPathParamsDTOSchema, }, response: { [HttpStatusCode.created]: { - schema: uploadResourceResponseBodyDTOSchema, - description: 'Resource uploaded.', + schema: uploadResourcesResponseBodyDTOSchema, + description: 'Resources uploaded.', }, }, }, securityMode: SecurityMode.bearer, tags: ['Resource'], - description: `Upload a Resource.`, + description: `Upload Resources.`, }), new HttpRoute({ method: HttpMethodName.post, @@ -320,29 +320,27 @@ export class ResourceHttpController implements HttpController { }; } - private async uploadResource( - request: HttpRequest, - ): Promise> { + private async uploadResources( + request: HttpRequest, + ): Promise> { const { userId } = await this.accessControlService.verifyBearerToken({ authorizationHeader: request.headers['authorization'], }); - if (!request.file) { + const files = request.files; + + if (!files || files.length === 0) { throw new OperationNotValidError({ - reason: 'File is required.', + reason: 'Files are required.', }); } const { bucketName } = request.pathParams; - const { name, type, data } = request.file; - await this.uploadResourceCommandHandler.execute({ userId, - resourceName: name, bucketName, - contentType: type, - data, + files, }); return { diff --git a/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/schemas/uploadResourceSchema.ts b/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/schemas/uploadResourceSchema.ts index f7d1bed..a95f324 100644 --- a/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/schemas/uploadResourceSchema.ts +++ b/apps/backend/src/modules/resourceModule/api/httpControllers/resourceHttpController/schemas/uploadResourceSchema.ts @@ -4,15 +4,15 @@ import type * as contracts from '@common/contracts'; import { type TypeExtends } from '../../../../../../common/types/schemaExtends.js'; -export const uploadResourcePathParamsDTOSchema = Type.Object({ +export const uploadResourcesPathParamsDTOSchema = Type.Object({ bucketName: Type.String({ minLength: 1 }), }); -export type UploadResourcePathParamsDTO = TypeExtends< - Static, - contracts.UploadResourcePathParams +export type UploadResourcesPathParamsDTO = TypeExtends< + Static, + contracts.UploadResourcesPathParams >; -export const uploadResourceResponseBodyDTOSchema = Type.Any(); +export const uploadResourcesResponseBodyDTOSchema = Type.Null(); -export type UploadResourceResponseBodyDTO = Static; +export type UploadResourcesResponseBodyDTO = Static; diff --git a/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.ts b/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.ts deleted file mode 100644 index ace6ac4..0000000 --- a/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { type Readable } from 'node:stream'; - -import { type CommandHandler } from '../../../../../common/types/commandHandler.js'; - -export interface UploadResourceCommandHandlerPayload { - readonly userId: string; - readonly resourceName: string; - readonly contentType: string; - readonly data: Readable; - readonly bucketName: string; -} - -export type UploadResourceCommandHandler = CommandHandler; diff --git a/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandlerImpl.ts b/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandlerImpl.ts deleted file mode 100644 index 2b1a92e..0000000 --- a/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandlerImpl.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - type UploadResourceCommandHandler, - type UploadResourceCommandHandlerPayload, -} from './uploadResourceCommandHandler.js'; -import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js'; -import { type LoggerService } from '../../../../../libs/logger/services/loggerService/loggerService.js'; -import { type FindUserBucketsQueryHandler } from '../../../../userModule/application/queryHandlers/findUserBucketsQueryHandler/findUserBucketsQueryHandler.js'; -import { type ResourceBlobService } from '../../../domain/services/resourceBlobService/resourceBlobService.js'; - -export class UploadResourceCommandHandlerImpl implements UploadResourceCommandHandler { - public constructor( - private readonly resourceBlobSerice: ResourceBlobService, - private readonly loggerService: LoggerService, - private readonly findUserBucketsQueryHandler: FindUserBucketsQueryHandler, - ) {} - - public async execute(payload: UploadResourceCommandHandlerPayload): Promise { - const { userId, resourceName, bucketName, contentType, data } = payload; - - const { buckets } = await this.findUserBucketsQueryHandler.execute({ userId }); - - if (!buckets.includes(bucketName)) { - throw new OperationNotValidError({ - reason: 'Bucket does not exist.', - userId, - bucketName, - }); - } - - this.loggerService.debug({ - message: 'Uploading Resource...', - userId, - bucketName, - resourceName, - contentType, - }); - - const existingResource = await this.resourceBlobSerice.resourceExists({ - bucketName, - resourceName, - }); - - if (existingResource) { - throw new OperationNotValidError({ - reason: 'Cannot create resource because it already exists.', - resourceName, - bucketName, - }); - } - - await this.resourceBlobSerice.uploadResource({ - bucketName, - resourceName, - data, - contentType, - }); - - this.loggerService.debug({ - message: 'Resource uploaded.', - userId, - bucketName, - resourceName, - contentType, - }); - } -} diff --git a/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.ts b/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.ts new file mode 100644 index 0000000..32b2831 --- /dev/null +++ b/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.ts @@ -0,0 +1,10 @@ +import { type CommandHandler } from '../../../../../common/types/commandHandler.js'; +import { type AttachedFile } from '../../../../../common/types/http/httpRequest.js'; + +export interface UploadResourcesCommandHandlerPayload { + readonly userId: string; + readonly bucketName: string; + readonly files: AttachedFile[]; +} + +export type UploadResourcesCommandHandler = CommandHandler; diff --git a/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandlerImpl.ts b/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandlerImpl.ts new file mode 100644 index 0000000..11d64e2 --- /dev/null +++ b/apps/backend/src/modules/resourceModule/application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandlerImpl.ts @@ -0,0 +1,73 @@ +import mime from 'mime'; +import { createReadStream } from 'node:fs'; + +import { + type UploadResourcesCommandHandler, + type UploadResourcesCommandHandlerPayload, +} from './uploadResourcesCommandHandler.js'; +import { OperationNotValidError } from '../../../../../common/errors/common/operationNotValidError.js'; +import { type LoggerService } from '../../../../../libs/logger/services/loggerService/loggerService.js'; +import { type FindUserBucketsQueryHandler } from '../../../../userModule/application/queryHandlers/findUserBucketsQueryHandler/findUserBucketsQueryHandler.js'; +import { type ResourceBlobService } from '../../../domain/services/resourceBlobService/resourceBlobService.js'; + +export class UploadResourcesCommandHandlerImpl implements UploadResourcesCommandHandler { + public constructor( + private readonly resourceBlobSerice: ResourceBlobService, + private readonly loggerService: LoggerService, + private readonly findUserBucketsQueryHandler: FindUserBucketsQueryHandler, + ) {} + + public async execute(payload: UploadResourcesCommandHandlerPayload): Promise { + const { userId, bucketName, files } = payload; + + const { buckets } = await this.findUserBucketsQueryHandler.execute({ userId }); + + if (!buckets.includes(bucketName)) { + throw new OperationNotValidError({ + reason: 'Bucket is not assigned to this user.', + userId, + bucketName, + }); + } + + await Promise.all( + files.map(async (file) => { + const { name: resourceName, filePath } = file; + + const contentType = mime.getType(filePath); + + if (!contentType) { + throw new OperationNotValidError({ + reason: 'Content type not found', + resourceName, + }); + } + + this.loggerService.debug({ + message: 'Uploading Resource...', + userId, + bucketName, + resourceName, + contentType, + }); + + const data = createReadStream(filePath); + + await this.resourceBlobSerice.uploadResource({ + bucketName, + resourceName, + data, + contentType, + }); + + this.loggerService.debug({ + message: 'Resource uploaded.', + userId, + bucketName, + resourceName, + contentType, + }); + }), + ); + } +} diff --git a/apps/backend/src/modules/resourceModule/resourceModule.ts b/apps/backend/src/modules/resourceModule/resourceModule.ts index bafcf01..fb48f5b 100644 --- a/apps/backend/src/modules/resourceModule/resourceModule.ts +++ b/apps/backend/src/modules/resourceModule/resourceModule.ts @@ -6,8 +6,8 @@ import { type DeleteBucketCommandHandler } from './application/commandHandlers/d import { DeleteBucketCommandHandlerImpl } from './application/commandHandlers/deleteBucketCommandHandler/deleteBucketCommandHandlerImpl.js'; import { type DeleteResourceCommandHandler } from './application/commandHandlers/deleteResourceCommandHandler/deleteResourceCommandHandler.js'; import { DeleteResourceCommandHandlerImpl } from './application/commandHandlers/deleteResourceCommandHandler/deleteResourceCommandHandlerImpl.js'; -import { type UploadResourceCommandHandler } from './application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandler.js'; -import { UploadResourceCommandHandlerImpl } from './application/commandHandlers/uploadResourceCommandHandler/uploadResourceCommandHandlerImpl.js'; +import { type UploadResourcesCommandHandler } from './application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandler.js'; +import { UploadResourcesCommandHandlerImpl } from './application/commandHandlers/uploadResourcesCommandHandler/uploadResourcesCommandHandlerImpl.js'; import { type DownloadImageQueryHandler } from './application/queryHandlers/downloadImageQueryHandler/downloadImageQueryHandler.js'; import { DownloadImageQueryHandlerImpl } from './application/queryHandlers/downloadImageQueryHandler/downloadImageQueryHandlerImpl.js'; import { type DownloadResourceQueryHandler } from './application/queryHandlers/downloadResourceQueryHandler/downloadResourceQueryHandler.js'; @@ -50,10 +50,10 @@ export class ResourceModule implements DependencyInjectionModule { ), ); - container.bind( - symbols.uploadResourceCommandHandler, + container.bind( + symbols.uploadResourcesCommandHandler, () => - new UploadResourceCommandHandlerImpl( + new UploadResourcesCommandHandlerImpl( container.get(symbols.resourceBlobService), container.get(coreSymbols.loggerService), container.get(userSymbols.findUserBucketsQueryHandler), @@ -140,7 +140,7 @@ export class ResourceModule implements DependencyInjectionModule { container.get(symbols.deleteResourceCommandHandler), container.get(symbols.findResourcesMetadataQueryHandler), container.get(symbols.downloadResourceQueryHandler), - container.get(symbols.uploadResourceCommandHandler), + container.get(symbols.uploadResourcesCommandHandler), container.get(symbols.downloadResourcesQueryHandler), container.get(symbols.downloadImageQueryHandler), container.get(symbols.downloadVideoPreviewQueryHandler), diff --git a/apps/backend/src/modules/resourceModule/symbols.ts b/apps/backend/src/modules/resourceModule/symbols.ts index 5d0917e..40b588f 100644 --- a/apps/backend/src/modules/resourceModule/symbols.ts +++ b/apps/backend/src/modules/resourceModule/symbols.ts @@ -7,7 +7,7 @@ export const symbols = { downloadImageQueryHandler: Symbol('downloadImageQueryHandler'), downloadVideoPreviewQueryHandler: Symbol('downloadVideoPreviewQueryHandler'), deleteResourceCommandHandler: Symbol('deleteResourceCommandHandler'), - uploadResourceCommandHandler: Symbol('uploadResourceCommandHandler'), + uploadResourcesCommandHandler: Symbol('uploadResourcesCommandHandler'), findBucketsQueryHandler: Symbol('findBucketsQueryHandler'), createBucketCommandHandler: Symbol('createBucketCommandHandler'), diff --git a/common/contracts/src/index.ts b/common/contracts/src/index.ts index 9e4d791..3ec37a1 100644 --- a/common/contracts/src/index.ts +++ b/common/contracts/src/index.ts @@ -40,4 +40,4 @@ export * from './schemas/resource/deleteBucket.js'; export * from './schemas/resource/downloadVideoPreview.js'; -export * from './schemas/resource/uploadResource.js'; +export * from './schemas/resource/uploadResources.js'; diff --git a/common/contracts/src/schemas/resource/uploadResource.ts b/common/contracts/src/schemas/resource/uploadResource.ts deleted file mode 100644 index 454a65b..0000000 --- a/common/contracts/src/schemas/resource/uploadResource.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface UploadResourcePathParams { - readonly bucketName: string; -} diff --git a/common/contracts/src/schemas/resource/uploadResources.ts b/common/contracts/src/schemas/resource/uploadResources.ts new file mode 100644 index 0000000..8e1632a --- /dev/null +++ b/common/contracts/src/schemas/resource/uploadResources.ts @@ -0,0 +1,3 @@ +export interface UploadResourcesPathParams { + readonly bucketName: string; +} diff --git a/package-lock.json b/package-lock.json index 77cc842..872dd00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "inversify": "6.0.2", "jsonwebtoken": "9.0.2", "knex": "3.1.0", + "mime": "^4.0.3", "node-fetch": "3.3.2", "pino": "9.0.0", "sharp": "0.33.3", @@ -116,6 +117,20 @@ "node": "^12.20 || >= 14.13" } }, + "apps/backend/node_modules/mime": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.0.3.tgz", + "integrity": "sha512-KgUb15Oorc0NEKPbvfa0wRU+PItIEZmiv+pyAO2i0oTIVTJhlzMclU7w4RXWQrSOVH5ax/p/CkIO7KI4OyFJTQ==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "apps/backend/node_modules/node-fetch": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",