alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
383 lines (346 loc) • 12.5 kB
text/typescript
import {
$inject,
createPrimitive,
type FileLike,
KIND,
Primitive,
type Service,
} from "alepha";
import { FileSystemProvider } from "alepha/system";
import { InvalidFileError } from "../errors/InvalidFileError.ts";
import { FileStorageProvider } from "../providers/FileStorageProvider.ts";
import { MemoryFileStorageProvider } from "../providers/MemoryFileStorageProvider.ts";
/**
* Creates a bucket primitive for file storage and management with configurable validation.
*
* Provides a comprehensive file storage system that handles uploads, downloads, validation,
* and management across multiple storage backends with MIME type and size limit controls.
*
* **Key Features**
* - Multi-provider support (filesystem, cloud storage, in-memory)
* - Automatic MIME type and file size validation
* - Event integration for file operations monitoring
* - Flexible per-bucket and per-operation configuration
* - Smart file type and size detection
*
* **Common Use Cases**
* - User profile pictures and document uploads
* - Product images and media management
* - Document storage and retrieval systems
*
* @example
* ```ts
* class MediaService {
* images = $bucket({
* name: "user-images",
* mimeTypes: ["image/jpeg", "image/png", "image/gif"],
* maxSize: 5 // 5MB limit
* });
*
* documents = $bucket({
* name: "documents",
* mimeTypes: ["application/pdf", "text/plain"],
* maxSize: 50 // 50MB limit
* });
*
* async uploadProfileImage(file: FileLike, userId: string): Promise<string> {
* const fileId = await this.images.upload(file);
* await this.userService.updateProfileImage(userId, fileId);
* return fileId;
* }
*
* async downloadDocument(documentId: string): Promise<FileLike> {
* return await this.documents.download(documentId);
* }
*
* async deleteDocument(documentId: string): Promise<void> {
* await this.documents.delete(documentId);
* }
* }
* ```
*/
export const $bucket = (options: BucketPrimitiveOptions) =>
createPrimitive(BucketPrimitive, options);
export interface BucketPrimitiveOptions extends BucketFileOptions {
/**
* File storage provider configuration for the bucket.
*
* Options:
* - **"memory"**: In-memory storage (default for development, lost on restart)
* - **Service<FileStorageProvider>**: Custom provider class (e.g., S3FileStorageProvider, AzureBlobProvider)
* - **undefined**: Uses the default file storage provider from dependency injection
*
* **Provider Selection Guidelines**:
* - **Development**: Use "memory" for fast, simple testing without external dependencies
* - **Production**: Use cloud providers (S3, Azure Blob, Google Cloud Storage) for scalability
* - **Local deployment**: Use filesystem providers for on-premise installations
* - **Hybrid**: Use different providers for different bucket types (temp files vs permanent storage)
*
* **Provider Capabilities**:
* - File persistence and durability guarantees
* - Scalability and performance characteristics
* - Geographic distribution and CDN integration
* - Cost implications for storage and bandwidth
* - Backup and disaster recovery features
*
* @default Uses injected FileStorageProvider
* @example "memory"
* @example S3FileStorageProvider
* @example AzureBlobStorageProvider
*/
provider?: Service<FileStorageProvider> | "memory";
/**
* Unique name identifier for the bucket.
*
* This name is used for:
* - Storage backend organization and partitioning
* - File path generation and URL construction
* - Logging, monitoring, and debugging
* - Access control and permissions management
* - Backup and replication configuration
*
* **Naming Conventions**:
* - Use lowercase with hyphens for consistency
* - Include purpose or content type in the name
* - Avoid spaces and special characters
* - Consider environment prefixes for deployment isolation
*
* If not provided, defaults to the property key where the bucket is declared.
*
* @example "user-avatars"
* @example "product-images"
* @example "legal-documents"
* @example "temp-processing-files"
*/
name?: string;
}
// ---------------------------------------------------------------------------------------------------------------------
export interface BucketFileOptions {
/**
* Human-readable description of the bucket's purpose and contents.
*
* Used for:
* - Documentation generation and API references
* - Developer onboarding and system understanding
* - Monitoring dashboards and admin interfaces
* - Compliance and audit documentation
*
* **Description Best Practices**:
* - Explain what types of files this bucket stores
* - Mention any special handling or processing requirements
* - Include information about retention policies if applicable
* - Note any compliance or security considerations
*
* @example "User profile pictures and avatar images"
* @example "Product catalog images with automated thumbnail generation"
* @example "Legal documents requiring long-term retention"
* @example "Temporary files for data processing workflows"
*/
description?: string;
/**
* Array of allowed MIME types for files uploaded to this bucket.
*
* When specified, only files with these exact MIME types will be accepted.
* Files with disallowed MIME types will be rejected with an InvalidFileError.
*
* **MIME Type Categories**:
* - Images: "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"
* - Documents: "application/pdf", "text/plain", "text/csv"
* - Office: "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
* - Archives: "application/zip", "application/x-tar", "application/gzip"
* - Media: "video/mp4", "audio/mpeg", "audio/wav"
*
* **Security Considerations**:
* - Always validate MIME types for user uploads
* - Be cautious with executable file types
* - Consider using allow-lists rather than deny-lists
* - Remember that MIME types can be spoofed by malicious users
*
* If not specified, all MIME types are allowed (not recommended for user uploads).
*
* @example ["image/jpeg", "image/png"] // Only JPEG and PNG images
* @example ["application/pdf", "text/plain"] // Documents only
* @example ["video/mp4", "video/webm"] // Video files
*/
mimeTypes?: string[];
/**
* Maximum file size allowed in megabytes (MB).
*
* Files larger than this limit will be rejected with an InvalidFileError.
* This helps prevent:
* - Storage quota exhaustion
* - Memory issues during file processing
* - Long upload times and timeouts
* - Abuse of storage resources
*
* **Size Guidelines by File Type**:
* - Profile images: 1-5 MB
* - Product photos: 5-10 MB
* - Documents: 10-50 MB
* - Video files: 50-500 MB
* - Data files: 100-1000 MB
*
* **Considerations**:
* - Consider your storage costs and limits
* - Factor in network upload speeds for users
* - Account for processing requirements (thumbnails, compression)
* - Set reasonable limits based on actual use cases
*
* @default 10 MB
*
* @example 1 // 1MB for small images
* @example 25 // 25MB for documents
* @example 100 // 100MB for media files
*/
maxSize?: number;
}
// ---------------------------------------------------------------------------------------------------------------------
export class BucketPrimitive extends Primitive<BucketPrimitiveOptions> {
public readonly provider = this.$provider();
protected readonly fileSystem = $inject(FileSystemProvider);
public get name() {
return this.options.name ?? `${this.config.propertyKey}`;
}
/**
* Uploads a file to the bucket.
*/
public async upload(
file: FileLike,
options?: BucketFileOptions,
): Promise<string> {
if (file instanceof File) {
// our createFile is smarter than the browser's File constructor
// by doing this, we can guess the MIME type and size!
file = this.fileSystem.createFile({ file });
}
options = {
...this.options,
...options,
};
const mimeTypes = options.mimeTypes ?? undefined;
const maxSize = options.maxSize ?? 10; // Default to 10 MB if not specified
if (mimeTypes) {
const mimeType = file.type || "application/octet-stream";
if (!mimeTypes.includes(mimeType)) {
throw new InvalidFileError(
`MIME type ${mimeType} is not allowed in bucket ${this.name}`,
);
}
}
// A reported size of 0 means the body is a one-shot stream — real streams
// report 0 until fully read (see FileLike.size). Materialize it once so
// that (a) the maxSize check below is accurate instead of being silently
// bypassed, and (b) the provider AND any `bucket:file:uploaded` hook can
// read the same bytes without draining the stream out from under each
// other. Files with a known size are already random-access (Blob/Buffer
// backed) and need no copy. Uploads are size-capped, so this is bounded.
if (file.size === 0) {
file = this.fileSystem.createFile({
arrayBuffer: await file.arrayBuffer(),
name: file.name,
type: file.type,
});
}
// check size in bytes, convert MB to bytes
if (file.size > maxSize * 1024 * 1024) {
throw new InvalidFileError(
`File size ${file.size} exceeds the maximum size of ${maxSize} MB in bucket ${this.name}`,
);
}
const id = await this.provider.upload(this.name, file);
await this.alepha.events.emit("bucket:file:uploaded", {
id,
bucket: this,
file,
options,
});
return id;
}
/**
* Delete permanently a file from the bucket.
*/
public async delete(fileId: string, skipHook = false): Promise<void> {
await this.provider.delete(this.name, fileId);
if (skipHook) {
return;
}
await this.alepha.events.emit("bucket:file:deleted", {
id: fileId,
bucket: this,
});
}
/**
* Delete many files in one round-trip when the underlying provider supports
* batch (R2/S3 up to 1000 keys per call). Emits one `bucket:file:deleted`
* event per id unless `skipHook` is set.
*/
public async deleteMany(fileIds: string[], skipHook = false): Promise<void> {
if (fileIds.length === 0) return;
await this.provider.deleteMany(this.name, fileIds);
if (skipHook) {
return;
}
for (const id of fileIds) {
await this.alepha.events.emit("bucket:file:deleted", {
id,
bucket: this,
});
}
}
/**
* Checks if a file exists in the bucket.
*/
public async exists(fileId: string): Promise<boolean> {
return this.provider.exists(this.name, fileId);
}
/**
* Lists the file identifiers stored in the bucket, like `ls` on a directory.
*
* This is a flat, unpaginated listing meant for small buckets — the result is
* capped at the provider's natural page size (~1000 for S3/R2) and anything
* beyond that is silently dropped. It is NOT a search API; reach for
* `alepha/api/files` when you need querying or pagination.
*/
public async list(): Promise<string[]> {
return this.provider.list(this.name);
}
/**
* Downloads a file from the bucket.
*/
public async download(fileId: string): Promise<FileLike> {
const file = await this.provider.download(this.name, fileId);
await this.alepha.events.emit("bucket:file:downloaded", {
id: fileId,
bucket: this,
file,
});
return file;
}
protected $provider() {
if (!this.options.provider) {
return this.alepha.inject(FileStorageProvider);
}
if (this.options.provider === "memory") {
return this.alepha.inject(MemoryFileStorageProvider);
}
return this.alepha.inject(this.options.provider);
}
}
$bucket[KIND] = BucketPrimitive;
// ---------------------------------------------------------------------------------------------------------------------
export interface BucketFileOptions {
/**
* Optional description of the bucket.
*/
description?: string;
/**
* Allowed MIME types.
*/
mimeTypes?: string[];
/**
* Maximum size of the files in the bucket.
*
* @default 10
*/
maxSize?: number;
}