From 4c342899f106be56bb5fa2efd8ac7d3b6d820f0a Mon Sep 17 00:00:00 2001 From: Maxim Date: Thu, 14 May 2026 08:25:04 +0300 Subject: [PATCH 1/2] refactor(s3): rework module instantiation with ConfigurableModuleBuilder --- libs/s3/src/interfaces/index.ts | 6 +-- libs/s3/src/interfaces/module.interface.ts | 36 +-------------- libs/s3/src/s3.constants.ts | 2 +- libs/s3/src/s3.module-definition.ts | 16 +++++++ libs/s3/src/s3.module.ts | 54 ++++++++-------------- libs/s3/src/s3.service.ts | 28 ++++------- src/shared/media/media.module.ts | 8 ++-- 7 files changed, 53 insertions(+), 97 deletions(-) create mode 100644 libs/s3/src/s3.module-definition.ts diff --git a/libs/s3/src/interfaces/index.ts b/libs/s3/src/interfaces/index.ts index 073bc43..dc08195 100644 --- a/libs/s3/src/interfaces/index.ts +++ b/libs/s3/src/interfaces/index.ts @@ -1,5 +1 @@ -export type { - S3ModuleOptions, - S3ModuleAsyncOptions, - S3ModuleOptionsFactory, -} from './module.interface'; +export type { S3ModuleOptions } from './module.interface'; diff --git a/libs/s3/src/interfaces/module.interface.ts b/libs/s3/src/interfaces/module.interface.ts index 1edd054..bfd2c94 100644 --- a/libs/s3/src/interfaces/module.interface.ts +++ b/libs/s3/src/interfaces/module.interface.ts @@ -1,38 +1,6 @@ import type { S3ClientConfig } from '@aws-sdk/client-s3'; -import type { FactoryProvider, ModuleMetadata, Provider, Type } from '@nestjs/common'; - -export interface S3ConnectionOptions extends Pick< - S3ClientConfig, - 'credentials' | 'endpoint' | 'region' -> { - bucket: string; -} - -export interface S3OtherOptions extends Omit< - S3ClientConfig, - 'credentials' | 'endpoint' | 'region' -> {} export interface S3ModuleOptions { - connection: S3ConnectionOptions; - config?: S3OtherOptions; - global?: boolean; -} - -export interface S3ModuleOptionsFactory { - createS3Options(): Promise | S3ModuleOptions; -} - -export interface S3ModuleAsyncOptions extends Pick< - ModuleMetadata, - 'imports' -> { - useExisting?: Type; - useClass?: Type; - useFactory?: ( - ...args: T - ) => Promise> | Omit; - inject?: FactoryProvider['inject']; - global?: boolean; - extraProviders?: Provider[]; + bucket: string; + clientConfig: S3ClientConfig; } diff --git a/libs/s3/src/s3.constants.ts b/libs/s3/src/s3.constants.ts index c55a9ab..58fc36c 100644 --- a/libs/s3/src/s3.constants.ts +++ b/libs/s3/src/s3.constants.ts @@ -1 +1 @@ -export const S3_OPTIONS = 'S3_OPTIONS'; +export const S3_CLIENT = 'S3_CLIENT'; diff --git a/libs/s3/src/s3.module-definition.ts b/libs/s3/src/s3.module-definition.ts new file mode 100644 index 0000000..3648deb --- /dev/null +++ b/libs/s3/src/s3.module-definition.ts @@ -0,0 +1,16 @@ +import { ConfigurableModuleBuilder } from '@nestjs/common'; +import { S3ModuleOptions } from './interfaces'; + +export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = + new ConfigurableModuleBuilder() + .setClassMethodName('register') + .setExtras( + { + global: false, + }, + (definition, extras) => ({ + ...definition, + global: extras.global, + }), + ) + .build(); diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts index 2c4b1f2..a61d0a5 100644 --- a/libs/s3/src/s3.module.ts +++ b/libs/s3/src/s3.module.ts @@ -1,37 +1,21 @@ -import { type DynamicModule, Module, type Provider } from '@nestjs/common'; -import type { S3ModuleOptions, S3ModuleAsyncOptions } from './interfaces'; +import { Module } from '@nestjs/common'; +import type { S3ModuleOptions } from './interfaces'; import { S3Service } from './s3.service'; -import { S3_OPTIONS } from './s3.constants'; +import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from './s3.module-definition'; +import { S3_CLIENT } from './s3.constants'; +import { S3Client } from '@aws-sdk/client-s3'; -@Module({}) -export class S3Module { - static register(options: S3ModuleOptions): DynamicModule { - const { global, ...config } = options; - - return { - global, - module: S3Module, - providers: [{ provide: S3_OPTIONS, useValue: config }, S3Service], - exports: [S3Service], - }; - } - - static registerAsync(options: S3ModuleAsyncOptions): DynamicModule { - const { imports } = options; - - return { - module: S3Module, - imports: imports || [], - providers: [this.createAsyncOptionsProvider(options), S3Service], - exports: [S3Service], - }; - } - - private static createAsyncOptionsProvider(options: S3ModuleAsyncOptions): Provider { - return { - provide: S3_OPTIONS, - useFactory: options.useFactory, - inject: options.inject || [], - }; - } -} +@Module({ + providers: [ + S3Service, + { + provide: S3_CLIENT, + inject: [MODULE_OPTIONS_TOKEN], + useFactory: (options: S3ModuleOptions) => { + return new S3Client(options.clientConfig); + }, + }, + ], + exports: [S3Service], +}) +export class S3Module extends ConfigurableModuleClass {} diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index 0201a6b..d3aa3dc 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -1,31 +1,23 @@ import { Inject, Injectable } from '@nestjs/common'; import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { S3_OPTIONS } from './s3.constants'; +import { S3_CLIENT } from './s3.constants'; import { S3ModuleOptions } from './interfaces'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { randomUUID } from 'crypto'; import { extname } from 'path'; +import { MODULE_OPTIONS_TOKEN } from './s3.module-definition'; @Injectable() export class S3Service { - private readonly s3Client: S3Client; - public readonly bucket: string; - private readonly endpoint: string; - constructor( - @Inject(S3_OPTIONS) + @Inject(S3_CLIENT) + private s3Client: S3Client, + @Inject(MODULE_OPTIONS_TOKEN) private options: S3ModuleOptions, - ) { - const { bucket, credentials, endpoint, region } = options.connection; - this.bucket = bucket; - this.endpoint = endpoint as string; + ) {} - this.s3Client = new S3Client({ - region, - endpoint, - credentials, - ...options.config, - }); + private get bucket(): string { + return this.options.bucket; } async deleteFile(fileUrl: string): Promise { @@ -47,7 +39,7 @@ export class S3Service { async uploadFile( file: Buffer, - options: { + fileOptions: { original: string; mimetype: string; cacheControl?: string; @@ -59,7 +51,7 @@ export class S3Service { | string; }, ): Promise { - const { mimetype, original, path, cacheControl } = options; + const { mimetype, original, path, cacheControl } = fileOptions; const folder = typeof path === 'object' ? path.folder : ''; const key = diff --git a/src/shared/media/media.module.ts b/src/shared/media/media.module.ts index 084e0c6..1881c94 100644 --- a/src/shared/media/media.module.ts +++ b/src/shared/media/media.module.ts @@ -15,17 +15,17 @@ import { MediaProcessor } from './workers/media.worker'; S3Module.registerAsync({ inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ - connection: { - bucket: cfg.getOrThrow('S3_BUCKET_NAME'), + bucket: cfg.getOrThrow('S3_BUCKET_NAME'), + clientConfig: { endpoint: cfg.getOrThrow('S3_ENDPOINT'), region: cfg.getOrThrow('S3_REGION'), credentials: { accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), }, + // FOR MINIO COMPARTABLE + forcePathStyle: true, }, - // FOR MINIO COMPARTABLE - config: { forcePathStyle: true }, }), }), ImagorModule.forRootAsync({ From 04b5cf75c3ddf77404942986bdfbcfed28b92f4b Mon Sep 17 00:00:00 2001 From: Danil <123034340+soorq@users.noreply.github.com> Date: Thu, 14 May 2026 15:08:20 +0300 Subject: [PATCH 2/2] refactor(s3): update types, add isAlive check and graceful shutdown implementation --- libs/s3/src/{s3.constants.ts => constants.ts} | 0 libs/s3/src/interfaces/module.interface.ts | 16 ++++++++- libs/s3/src/s3.module.ts | 25 +++++++++++--- libs/s3/src/s3.service.ts | 33 +++++++++++++------ src/shared/media/media.module.ts | 11 ++++--- src/shared/media/media.service.ts | 2 +- src/shared/media/workers/media.worker.ts | 2 +- 7 files changed, 67 insertions(+), 22 deletions(-) rename libs/s3/src/{s3.constants.ts => constants.ts} (100%) diff --git a/libs/s3/src/s3.constants.ts b/libs/s3/src/constants.ts similarity index 100% rename from libs/s3/src/s3.constants.ts rename to libs/s3/src/constants.ts diff --git a/libs/s3/src/interfaces/module.interface.ts b/libs/s3/src/interfaces/module.interface.ts index bfd2c94..922d127 100644 --- a/libs/s3/src/interfaces/module.interface.ts +++ b/libs/s3/src/interfaces/module.interface.ts @@ -1,6 +1,20 @@ import type { S3ClientConfig } from '@aws-sdk/client-s3'; +export interface S3Connection extends Pick< + S3ClientConfig, + 'credentials' | 'endpoint' | 'region' +> { + endpoint: string; + region: string; +} + +export interface S3Config extends Omit< + S3ClientConfig, + keyof S3Connection +> { } + export interface S3ModuleOptions { + connection: S3Connection; bucket: string; - clientConfig: S3ClientConfig; + config?: S3Config; } diff --git a/libs/s3/src/s3.module.ts b/libs/s3/src/s3.module.ts index a61d0a5..656b979 100644 --- a/libs/s3/src/s3.module.ts +++ b/libs/s3/src/s3.module.ts @@ -1,21 +1,36 @@ -import { Module } from '@nestjs/common'; +import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; import type { S3ModuleOptions } from './interfaces'; import { S3Service } from './s3.service'; import { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } from './s3.module-definition'; -import { S3_CLIENT } from './s3.constants'; +import { S3_CLIENT } from './constants'; import { S3Client } from '@aws-sdk/client-s3'; @Module({ providers: [ - S3Service, { provide: S3_CLIENT, inject: [MODULE_OPTIONS_TOKEN], useFactory: (options: S3ModuleOptions) => { - return new S3Client(options.clientConfig); + const { connection, config } = options; + + return new S3Client({ + ...connection, + ...config + }); }, }, + S3Service, ], exports: [S3Service], }) -export class S3Module extends ConfigurableModuleClass {} +export class S3Module extends ConfigurableModuleClass implements OnApplicationShutdown { + constructor( + @Inject(S3_CLIENT) private readonly client: S3Client + ) { + super(); + } + + async onApplicationShutdown() { + this.client.destroy(); + } +} diff --git a/libs/s3/src/s3.service.ts b/libs/s3/src/s3.service.ts index d3aa3dc..e42ba07 100644 --- a/libs/s3/src/s3.service.ts +++ b/libs/s3/src/s3.service.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; -import { S3_CLIENT } from './s3.constants'; +import { DeleteObjectCommand, HeadBucketCommand, S3Client } from '@aws-sdk/client-s3'; +import { S3_CLIENT } from './constants'; import { S3ModuleOptions } from './interfaces'; import { PutObjectCommand } from '@aws-sdk/client-s3'; import { randomUUID } from 'crypto'; @@ -14,13 +14,26 @@ export class S3Service { private s3Client: S3Client, @Inject(MODULE_OPTIONS_TOKEN) private options: S3ModuleOptions, - ) {} + ) { } private get bucket(): string { return this.options.bucket; } - async deleteFile(fileUrl: string): Promise { + async isAlive(): Promise { + try { + await this.s3Client.send( + new HeadBucketCommand({ + Bucket: this.bucket, + }), + ); + return true; + } catch (error) { + return false; + } + } + + async delete(fileUrl: string): Promise { try { const url = new URL(fileUrl); const pathParts = url.pathname.split('/'); @@ -37,18 +50,18 @@ export class S3Service { } } - async uploadFile( + async upload( file: Buffer, fileOptions: { original: string; mimetype: string; cacheControl?: string; path?: - | { - folder: string; - key?: string; - } - | string; + | { + folder: string; + key?: string; + } + | string; }, ): Promise { const { mimetype, original, path, cacheControl } = fileOptions; diff --git a/src/shared/media/media.module.ts b/src/shared/media/media.module.ts index 1881c94..d1d9747 100644 --- a/src/shared/media/media.module.ts +++ b/src/shared/media/media.module.ts @@ -16,16 +16,19 @@ import { MediaProcessor } from './workers/media.worker'; inject: [ConfigService], useFactory: (cfg: ConfigService) => ({ bucket: cfg.getOrThrow('S3_BUCKET_NAME'), - clientConfig: { + connection: { endpoint: cfg.getOrThrow('S3_ENDPOINT'), region: cfg.getOrThrow('S3_REGION'), credentials: { accessKeyId: cfg.getOrThrow('S3_ACCESS_KEY'), secretAccessKey: cfg.getOrThrow('S3_SECRET_KEY'), }, - // FOR MINIO COMPARTABLE - forcePathStyle: true, }, + config: { + connectTimeout: 2000, + requestTimeout: 5000, + maxAttempts: 3, + } }), }), ImagorModule.forRootAsync({ @@ -55,4 +58,4 @@ import { MediaProcessor } from './workers/media.worker'; controllers: [MediaController], providers: [MediaProcessor, MediaService], }) -export class MediaModule {} +export class MediaModule { } diff --git a/src/shared/media/media.service.ts b/src/shared/media/media.service.ts index 9f045e0..c2f011b 100644 --- a/src/shared/media/media.service.ts +++ b/src/shared/media/media.service.ts @@ -24,7 +24,7 @@ export class MediaService { const { folder, fileName } = this.generateStoragePath(context, userId, file.filename); try { - const originalUrl = await this.s3.uploadFile(file.buffer, { + const originalUrl = await this.s3.upload(file.buffer, { mimetype: file.mimetype, original: file.filename, path: { folder, key: fileName }, diff --git a/src/shared/media/workers/media.worker.ts b/src/shared/media/workers/media.worker.ts index 00ccf2e..3f5f8b0 100644 --- a/src/shared/media/workers/media.worker.ts +++ b/src/shared/media/workers/media.worker.ts @@ -34,7 +34,7 @@ export class MediaProcessor extends WorkerHost { const processedImage = await this.imagor.get(`/${originalFilePath}`, dimensions); - const uploadedPath = await this.s3.uploadFile(processedImage, { + const uploadedPath = await this.s3.upload(processedImage, { original: targetFileName, mimetype: 'image/webp', path: {