npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tony4ork/nest-upload

v0.3.1

Published

Shared upload module for NestJS: presigned URLs, upload through the backend, validation, key generation — with no dependency on a specific provider (S3/R2/MinIO, GCS, Local, Memory).

Readme

@tony4ork/nest-upload

Shared upload module for NestJS: presigned URLs, upload through the backend, MIME/size validation, safe key generation — with no dependency on a specific provider. Switching storage (S3 ↔ GCS ↔ Local ↔ Memory) only requires changing the object passed to storage:, no controller/service business logic changes needed.

  • UploadModule / UploadService: shared logic, exported from the root subpath @tony4ork/nest-upload.
  • 4 built-in providers, each on its own subpath so unnecessary SDKs don't get pulled into the bundle:
    • @tony4ork/nest-upload/s3 — S3, compatible with Cloudflare R2 / MinIO / DigitalOcean Spaces.
    • @tony4ork/nest-upload/gcs — Google Cloud Storage.
    • @tony4ork/nest-upload/local — writes to the local disk, for dev only.
    • @tony4ork/nest-upload/testing — MemoryStorageProvider + a shared contract test suite, for unit tests.

Installation

# pnpm
pnpm add @tony4ork/nest-upload

# yarn
yarn add @tony4ork/nest-upload

# npm
npm install @tony4ork/nest-upload

Optional peer dependencies, only needed for the provider(s) you actually use:

# Only if you use the S3 provider:
pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner

# Only if you sign download URLs for a private CloudFront distribution (see below):
pnpm add @aws-sdk/cloudfront-signer

# Only if you use the GCS provider:
pnpm add @google-cloud/storage

@nestjs/common (^10.0.0 || ^11.0.0) is a required peer dependency. The Local and Memory providers need nothing extra installed.

Quick start

  1. Install the package (and the SDK for the provider you plan to use, see above).
  2. Create an integration file that builds UploadModule.forRoot() / forRootAsync() with your chosen storage provider (examples below, one per provider).
  3. Import that module in AppModule.
  4. Inject UploadService in a controller/service and call createPresignedUpload, upload, confirmUpload, getDownloadUrl, or delete — see Usage flows below.

Configuration

S3 (production) — forRootAsync with ConfigService

// src/integrations/upload.integration.ts
import { ConfigService } from '@nestjs/config';
import { UploadModule } from '@tony4ork/nest-upload';
import { S3StorageProvider } from '@tony4ork/nest-upload/s3';

export const UploadIntegration = UploadModule.forRootAsync({
  isGlobal: true,
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    storage: new S3StorageProvider({
      bucket: config.getOrThrow('UPLOAD_S3_BUCKET'),
      clientConfig: {
        region: config.get('UPLOAD_S3_REGION', 'auto'),
        endpoint: config.get('UPLOAD_S3_ENDPOINT'), // leave unset to use real AWS S3
        forcePathStyle: config.get('UPLOAD_S3_FORCE_PATH_STYLE') === 'true',
        // Omit credentials to let the SDK use the default credential chain (IAM role).
      },
      publicBaseUrl: config.get('UPLOAD_S3_PUBLIC_BASE_URL'), // CDN/CloudFront, leave unset if the bucket is private
    }),
    keyPrefix: `${config.get('NODE_ENV')}/${config.getOrThrow('SERVICE_NAME')}`,
    presignExpiresIn: 300,
    maxFileSize: 20 * 1024 * 1024, // 20MB
    allowedMimeTypes: ['image/*', 'application/pdf'],
  }),
});
// app.module.ts
@Module({ imports: [UploadIntegration, /* ... */] })
export class AppModule {}

Cloudflare R2 / MinIO / DigitalOcean Spaces

S3StorageProvider uses @aws-sdk/client-s3, so it works with any S3-compatible service — just set endpoint + forcePathStyle: true:

new S3StorageProvider({
  bucket: 'my-bucket',
  clientConfig: {
    region: 'auto', // R2 uses 'auto'; MinIO/Spaces accept any region, e.g. 'us-east-1'
    endpoint: 'https://<account-id>.r2.cloudflarestorage.com', // or http://localhost:9000 for MinIO
    forcePathStyle: true, // required for MinIO; also recommended for R2/Spaces
    credentials: { accessKeyId: '...', secretAccessKey: '...' },
  },
});

Private CloudFront distribution (signed URLs)

If the CloudFront distribution in front of the bucket is private (Origin Access Control + a trusted key group, no public read), pass cloudfrontSigner with a CloudFront key pair. This changes getDownloadUrl's behavior: getPublicUrl always returns undefined, so it falls back to presignDownload, which now signs publicBaseUrl (the CloudFront domain) instead of the S3 bucket directly:

pnpm add @aws-sdk/cloudfront-signer
new S3StorageProvider({
  bucket: config.getOrThrow('UPLOAD_S3_BUCKET'),
  clientConfig: { region: config.get('UPLOAD_S3_REGION') },
  publicBaseUrl: config.getOrThrow('UPLOAD_CLOUDFRONT_DOMAIN'), // e.g. https://dxxxx.cloudfront.net, required
  cloudfrontSigner: {
    keyPairId: config.getOrThrow('UPLOAD_CLOUDFRONT_KEY_PAIR_ID'), // id of the public key registered in CloudFront
    privateKey: config.getOrThrow('UPLOAD_CLOUDFRONT_PRIVATE_KEY'), // PEM private key paired with it — keep this secret
  },
});

Only downloads are signed this way — uploads still go straight to S3 via createPresignedUpload/upload, which is standard practice (CloudFront isn't meant to sit in front of writes). Generate the CloudFront key pair (RSA, PEM) and register the public key + a key group on the distribution in the AWS console/CLI first; the private key never leaves your backend.

GCS

// src/integrations/upload.integration.ts
import { ConfigService } from '@nestjs/config';
import { UploadModule } from '@tony4ork/nest-upload';
import { GcsStorageProvider } from '@tony4ork/nest-upload/gcs';

export const UploadIntegration = UploadModule.forRootAsync({
  isGlobal: true,
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    storage: new GcsStorageProvider({
      bucket: config.getOrThrow('UPLOAD_GCS_BUCKET'),
      storageOptions: {
        projectId: config.get('UPLOAD_GCS_PROJECT_ID'),
        // Omit credentials to let the SDK use Application Default Credentials
        // (a service account attached to GKE/Cloud Run/GCE, or GOOGLE_APPLICATION_CREDENTIALS).
      },
      publicBaseUrl: config.get('UPLOAD_GCS_PUBLIC_BASE_URL'), // CDN/public bucket, leave unset if the bucket is private
    }),
    keyPrefix: `${config.get('NODE_ENV')}/${config.getOrThrow('SERVICE_NAME')}`,
    allowedMimeTypes: ['image/*', 'application/pdf'],
  }),
});

Presigned GCS URLs (V4 signing) require the service account's client_email + private_key to self-sign locally — use a JSON key file via GOOGLE_APPLICATION_CREDENTIALS, or pass storageOptions.credentials explicitly if running on an environment without ADC support (e.g. some non-GCP PaaS using Workload Identity Federation).

Local (dev)

import { LocalStorageProvider } from '@tony4ork/nest-upload/local';

UploadModule.forRoot({
  storage: new LocalStorageProvider({ rootDir: './uploads', publicBaseUrl: 'http://localhost:3000/uploads' }),
});

Test (Jest)

import { Test } from '@nestjs/testing';
import { UploadModule } from '@tony4ork/nest-upload';
import { MemoryStorageProvider } from '@tony4ork/nest-upload/testing';

const moduleRef = await Test.createTestingModule({
  imports: [UploadModule.forRoot({ storage: new MemoryStorageProvider() })],
}).compile();

isGlobal

forRoot/forRootAsync accept an extra isGlobal?: boolean (defaults to false) to register UploadModule as a global module, so it doesn't need to be re-imported in every feature module.

Usage flow: presign → client upload → confirm

The client uploads directly to storage (bypassing the backend), which suits large files.

@Controller('avatars')
export class AvatarController {
  constructor(private readonly uploadService: UploadService) {}

  @Post('presign')
  async presign(@Body() dto: PresignAvatarDto) {
    // { folder: 'avatars', fileName, contentType, size } — the key is generated server-side, never from the original fileName.
    return this.uploadService.createPresignedUpload({
      folder: 'avatars',
      fileName: dto.fileName,
      contentType: dto.contentType,
      size: dto.size,
    });
    // -> { key, expiresIn, url, method: 'PUT', headers: { 'Content-Type': ... } }
  }

  @Post('confirm')
  async confirm(@Body('key') key: string) {
    // Re-validates the actual size/contentType on storage; on mismatch, deletes the object and throws.
    return this.uploadService.confirmUpload(key);
    // -> { key, size, contentType, publicUrl? }
  }
}

The client must use the returned method/headers/fields as-is to upload (never guess them):

const presigned = await api.post('/avatars/presign', { fileName, contentType, size });
await fetch(presigned.url, { method: presigned.method, headers: presigned.headers, body: file });
await api.post('/avatars/confirm', { key: presigned.key });

Usage flow: upload through the backend

The file goes through the server first, which suits cases where extra processing is needed (per-row auth, antivirus at another layer, etc.) or small files.

@Controller('avatars')
export class AvatarController {
  constructor(private readonly uploadService: UploadService) {}

  @Post()
  @UseInterceptors(FileInterceptor('file'))
  async upload(@UploadedFile() file: Express.Multer.File) {
    return this.uploadService.upload({
      folder: 'avatars',
      fileName: file.originalname,
      contentType: file.mimetype,
      size: file.size,
      body: file.buffer, // or a stream if using a Multer storage engine other than memory
    });
    // -> { key, size, contentType, publicUrl? }
  }
}

If body is a Buffer, size can be omitted — upload() derives it from the buffer's length so maxFileSize still applies. If body is a stream (e.g. a disk-storage Multer engine) and size isn't passed, maxFileSize cannot be enforced before the data is already written to storage — always also set limits: { fileSize } on FileInterceptor/Multer so oversized uploads are rejected before they reach UploadService.

API

| Method | Description | |---|---| | createPresignedUpload({ folder, fileName, contentType, size? }) | Validates → generates a key → returns the URL/headers for the client to PUT directly (or POST a form). Throws UnsupportedOperationError if the provider doesn't support presign. | | confirmUpload(key) | Checks that the object exists and matches the declared size/contentType. On mismatch, deletes the object then throws. Throws UploadNotFoundError if it doesn't exist. | | upload({ folder, fileName, contentType, size?, body }) | Validates → generates a key → writes directly through the backend. | | getDownloadUrl(key, expiresIn = 300) | Prefers getPublicUrl (if the provider is configured with a public URL), otherwise falls back to presignDownload. Throws UnsupportedOperationError if neither is available. | | delete(key) | Deletes the object, idempotent. |

Options (UploadModuleOptions)

| Option | Default | Notes | |---|---|---| | storage | (required) | An instance implementing StorageProvider. | | keyPrefix | — | Prepended to every key, e.g. prod/order-service. | | presignExpiresIn | 300 | Seconds, used when expiresIn isn't passed explicitly. | | maxFileSize | unlimited | Bytes. When set, createPresignedUpload requires size to be passed (throws UploadValidationError otherwise) — without it, the limit can't be enforced before the client uploads straight to storage. | | allowedMimeTypes | unlimited | Supports wildcards like 'image/*'. | | keyStrategy | default key generator | Overrides how keys are generated, receives { folder, fileName, contentType } with an already-sanitized folder. |

Key generation

Default: {keyPrefix}/{folder}/{yyyy}/{mm}/{uuidv4}.{ext} (UTC). The original file name is never used. folder only allows [a-z0-9-_/], must not be empty, must not contain .., and must not start/end with / — violations throw UploadValidationError. If the file's extension doesn't match contentType (e.g. .exe with image/png), the key uses the extension derived from contentType instead.

Errors

| Error | HTTP | When | |---|---|---| | UploadValidationError | 400 | Invalid MIME/size/folder. | | UploadNotFoundError | 404 | confirmUpload called on a key that doesn't exist. | | UnsupportedOperationError | 501 | The current provider doesn't support the operation (e.g. Local has no presign). | | StorageProviderError | — | The provider's original SDK error, wrapped (the raw error never leaks out). Carries provider, operation, cause. |

UploadValidationError/UploadNotFoundError/UnsupportedOperationError all extend Nest's HttpException, so they can be used directly without a separate catch.

Writing a new provider (Azure Blob...)

  1. Implement the StorageProvider interface (imported from @tony4ork/nest-upload):
    import { StorageProvider, PutObjectInput, ObjectMeta, PresignUploadInput, PresignedUpload } from '@tony4ork/nest-upload';
    
    export class AzureBlobStorageProvider implements StorageProvider {
      readonly name = 'azure-blob';
      async put(input: PutObjectInput): Promise<void> { /* ... */ }
      async head(key: string): Promise<ObjectMeta | null> { /* return null if it doesn't exist, do NOT throw */ }
      async delete(key: string): Promise<void> { /* idempotent */ }
      // presignUpload / presignDownload / getPublicUrl: optional, only implement if the service supports it.
    }
  2. Wrap the original SDK error with StorageProviderError(name, operation, cause); never let the raw SDK error leak out.
  3. Run the shared contract test suite to make sure the provider behaves correctly per the StorageProvider contract:
    import { runStorageProviderContractTests } from '@tony4ork/nest-upload/testing';
    runStorageProviderContractTests('azure-blob', () => new AzureBlobStorageProvider({ /* ... */ }));
  4. No changes needed in UploadModule/UploadService — just pass the new instance to storage: when configuring the module. See src/providers/s3/ or src/providers/gcs/ for reference (file layout, error wrapping, how to write a provider-specific contract test in test/contract/ or test/integration/).

Known limitations

  • contentType is trusted, not verified against the actual bytes. allowedMimeTypes and the key's extension are both derived from the contentType the caller/client declares, not from sniffing the file's magic bytes. For S3/GCS presigned uploads, the signature does bind the client to sending that exact Content-Type header, but nothing confirms the body itself is really that format (e.g. a renamed executable sent as image/png with a matching header still passes). If stricter guarantees are needed, add magic-byte sniffing (e.g. via file-type) in confirmUpload's business-layer caller, or scan the object after upload.

Roadmap (not yet done)

  • Azure Blob provider (the interface is already ready for it, see above).
  • Multiple storages at once in the same app (named disks, e.g. disk: 'avatars' vs disk: 'documents' using two different providers). Currently: register multiple UploadModule.forRootAsync calls with different DI tokens yourself if needed — the package doesn't support this out of the box yet.
  • Saving metadata to a DB, image resizing/processing, virus scanning: out of scope for this package — handle these at your own business layer.

Test

pnpm test              # unit + contract (Memory, Local) — no Docker required
pnpm test:cov          # with coverage (core ≥ 90%)
docker compose -f docker-compose.test.yml up -d
pnpm test:integration  # contract + real presign flow for S3 (via S3Mock) and GCS (via fake-gcs-server)
docker compose -f docker-compose.test.yml down

docker-compose.test.yml uses S3Mock instead of MinIO because the official MinIO image is no longer publicly pullable from Docker Hub (MinIO changed its distribution policy in 2024), and fake-gcs-server for GCS. To run against a real backend instead:

  • S3: set S3_TEST_ENDPOINT, S3_TEST_REGION, S3_TEST_ACCESS_KEY_ID, S3_TEST_SECRET_ACCESS_KEY, S3_TEST_BUCKET (works with MinIO/LocalStack/real S3).
  • GCS: set GCS_TEST_API_ENDPOINT, GCS_TEST_PROJECT_ID, GCS_TEST_BUCKET.

test:integration runs with NODE_OPTIONS=--experimental-vm-modules (already set in the script) because @google-cloud/storage uses an internal dynamic import() that Jest's CJS VM blocks without this flag — no need to worry about this if you only run pnpm test.