@neoma/anubis
v0.1.0
Published
NestJS-idiomatic file storage — upload, persist, download — for S3-compatible backends.
Downloads
109
Maintainers
Readme
@neoma/anubis
NestJS-idiomatic file storage for S3-compatible backends.
Anubis (the jackal-headed Egyptian god who guarded the tombs) handles the file upload lifecycle: store to S3, persist metadata to your entity, and generate presigned download URLs -- all through interceptors and decorators that compose naturally with NestJS controllers.
Installation
npm install @neoma/anubisPeer dependencies
npm install @nestjs/common @nestjs/core @nestjs/platform-express @nestjs/typeorm @nestjs/event-emitter typeorm reflect-metadata rxjsQuick Start
1. Define your entity
Your entity implements the Storable interface:
import { type Storable } from "@neoma/anubis"
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"
@Entity()
export class Upload implements Storable {
@PrimaryGeneratedColumn("uuid")
public id!: string
@Column()
public originalName!: string
@Column()
public mimeType!: string
@Column()
public size!: number
@Column()
public key!: string
@Column()
public bucket!: string
// Add your own columns
@Column({ nullable: true })
public source?: string
}2. Register the module
import { AnubisModule } from "@neoma/anubis"
@Module({
imports: [
TypeOrmModule.forRoot({ ... }),
AnubisModule.forRoot({
endpoint: "http://localhost:9000",
region: "us-east-1",
bucket: "uploads",
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
entity: Upload,
maxFileSize: 10_000_000, // 10MB global limit
}),
],
})
export class AppModule {}Or with async configuration:
AnubisModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
endpoint: config.get("S3_ENDPOINT"),
region: config.get("S3_REGION"),
bucket: config.get("S3_BUCKET"),
accessKeyId: config.get("S3_ACCESS_KEY_ID"),
secretAccessKey: config.get("S3_SECRET_ACCESS_KEY"),
entity: Upload,
}),
inject: [ConfigService],
})3. Use in your controller
import {
Upload as UploadDecorator,
StoredFile,
TemporaryLink,
} from "@neoma/anubis"
@Controller("uploads")
export class UploadController {
public constructor(private readonly dataSource: DataSource) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@UploadDecorator()
public create(@StoredFile() file: Upload): Upload {
file.source = "form"
return file
}
@Post("csv")
@HttpCode(HttpStatus.CREATED)
@UploadDecorator({ types: ["text/csv"], maxSize: 1_000_000 })
public importCsv(@StoredFile() file: Upload): Upload {
file.source = "csv-import"
return file
}
@Get(":id")
@TemporaryLink()
public async download(@Param("id") id: string): Promise<Upload> {
return this.dataSource.getRepository(Upload).findOneByOrFail({ id })
}
}How it works
Upload (@Upload()):
- Middleware: Multer parses the multipart request (memory storage, single file)
- Pre-handler: Interceptor validates the file (size, type), uploads to S3, creates the entity, attaches it to the request
- Your handler: Receives the entity via
@StoredFile(), mutates any additional fields, returns the HTTP response - Post-handler: Interceptor persists the entity to the database, emits
FileCreatedEvent
Download (@TemporaryLink()):
- Your handler: Returns a
Storableentity (e.g. from database lookup) - Post-handler: Interceptor generates a presigned S3 URL and responds with HTTP 302 redirect
Events
After a successful upload and entity persistence, a FileCreatedEvent is emitted:
import { FileCreatedEvent } from "@neoma/anubis"
import { OnEvent } from "@nestjs/event-emitter"
@Injectable()
export class UploadProcessor {
@OnEvent(FileCreatedEvent.EVENT_NAME)
public handleFileCreated(event: FileCreatedEvent<Upload>): void {
console.log("File created:", event.entity.key)
}
}Events are fire-and-forget -- listener errors do not affect the upload response.
Exceptions
All exceptions extend HttpException and include metadata for diagnostics:
| Exception | Status | Properties |
|-----------|--------|------------|
| NoFileProvidedException | 400 | message |
| FileTooLargeException | 413 | fileSize: number \| null, maxSize: number |
| UnsupportedFileTypeException | 415 | mimeType: string, allowedTypes: string[] |
| FileStoreUnreachableException | 503 | endpoint: string, bucket: string, cause: string |
FileTooLargeException.fileSize is null when multer rejects the file before buffering (actual size unknown).
API Reference
AnubisOptions
| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| endpoint | string | Yes | | S3-compatible endpoint URL |
| region | string | Yes | | AWS region |
| bucket | string | Yes | | S3 bucket name |
| accessKeyId | string | Yes | | AWS access key ID |
| secretAccessKey | string | Yes | | AWS secret access key |
| entity | new () => T | Yes | | TypeORM entity class implementing Storable |
| prefix | string | No | | Key prefix for uploaded files |
| maxFileSize | number | No | | Global maximum file size in bytes |
| allowedMimeTypes | string[] | No | | Global allowed MIME types |
| linkExpiresIn | number | No | 3600 | Default presigned URL expiry in seconds |
| forcePathStyle | boolean | No | true | Use path-style S3 URLs (required for MinIO) |
Storable interface
The contract your entity must implement:
interface Storable {
id: any
originalName: string
mimeType: string
size: number
key: string
bucket: string
}@Upload(options?)
Method decorator for upload routes. Global limits are the ceiling; per-route narrows within it.
@Upload() // defaults: field "file", no per-route limits
@Upload({ field: "avatar" }) // custom field name
@Upload({ maxSize: 1_000_000 }) // per-route size limit (1MB)
@Upload({ types: ["text/csv"] }) // per-route type restriction
@Upload({ field: "doc", maxSize: 5_000_000 }) // combined@StoredFile()
Parameter decorator that extracts the stored file entity from the request.
@TemporaryLink(options?)
Method decorator for download routes. Handler must return a Storable entity.
@TemporaryLink() // default expiry from AnubisOptions.linkExpiresIn
@TemporaryLink({ expiresIn: 600 }) // 10 minute expiryFileCreatedEvent
Emitted after successful upload + persistence. EVENT_NAME = "anubis.file.created".
StorageService
Injected via DI. Wraps @aws-sdk/client-s3.
store(originalName, buffer, contentType): Promise<string>-- Upload a file, returns the generated keygetSignedUrl(key, expiresIn?): Promise<string>-- Generate a presigned download URL
Storage Key Format
Keys are generated as ${prefix}/${ulid}-${originalName} (or ${ulid}-${originalName} without a prefix). ULIDs provide time-ordered uniqueness. Filenames are sanitised to strip directory components.
License
MIT
