UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

595 lines (594 loc) 20.2 kB
import { $env, $hook, $inject, $module, Alepha, AlephaError, KIND, Primitive, createPrimitive, z } from "alepha"; import { FileDetector, FileSystemProvider } from "alepha/system"; import { CryptoProvider } from "alepha/crypto"; import { currentTenantAtom } from "alepha/security"; import { $logger } from "alepha/logger"; import { Buffer as Buffer$1 } from "node:buffer"; import { Readable } from "node:stream"; import { S3mini } from "s3mini"; //#region ../../src/bucket/errors/InvalidFileError.ts var InvalidFileError = class extends AlephaError { status = 400; }; //#endregion //#region ../../src/bucket/providers/FileStorageProvider.ts var FileStorageProvider = class {}; //#endregion //#region ../../src/bucket/errors/FileNotFoundError.ts var FileNotFoundError = class extends AlephaError { status = 404; }; //#endregion //#region ../../src/bucket/providers/MemoryFileStorageProvider.ts var MemoryFileStorageProvider = class { files = {}; alepha = $inject(Alepha); fileSystem = $inject(FileSystemProvider); fileDetector = $inject(FileDetector); crypto = $inject(CryptoProvider); async upload(bucketName, file, fileId) { fileId ??= this.createId(); const chunks = []; for await (const chunk of file.stream()) chunks.push(chunk); const buffer = Buffer.concat(chunks); this.files[this.key(bucketName, fileId)] = { buffer, name: file.name, type: file.type, size: file.size }; return fileId; } async download(bucketName, fileId) { const fileKey = this.key(bucketName, fileId); const stored = this.files[fileKey]; if (!stored) throw new FileNotFoundError(`File with ID ${fileId} not found.`); return this.fileSystem.createFile({ stream: new Blob([new Uint8Array(stored.buffer)]).stream(), name: stored.name, type: stored.type, size: stored.size }); } async exists(bucketName, fileId) { return this.key(bucketName, fileId) in this.files; } async delete(bucketName, fileId) { const fileKey = this.key(bucketName, fileId); if (!(fileKey in this.files)) throw new FileNotFoundError(`File with ID ${fileId} not found.`); delete this.files[fileKey]; } async deleteMany(bucketName, fileIds) { for (const id of fileIds) delete this.files[this.key(bucketName, id)]; } async list(bucketName) { const prefix = this.key(bucketName, ""); return Object.keys(this.files).filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length)); } /** * In-memory key, tenant-scoped when a tenant is active (`currentTenantAtom`), * mirroring the R2/Local/S3 layout: `{tenantId}/{bucket}/{fileId}`. */ key(bucket, fileId = "") { const tenantId = this.alepha.store.get(currentTenantAtom)?.id; return tenantId ? `${tenantId}/${bucket}/${fileId}` : `${bucket}/${fileId}`; } createId() { return this.crypto.randomUUID(); } }; //#endregion //#region ../../src/bucket/primitives/$bucket.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); * } * } * ``` */ const $bucket = (options) => createPrimitive(BucketPrimitive, options); var BucketPrimitive = class extends Primitive { provider = this.$provider(); fileSystem = $inject(FileSystemProvider); get name() { return this.options.name ?? `${this.config.propertyKey}`; } /** * Uploads a file to the bucket. */ async upload(file, options) { if (file instanceof File) file = this.fileSystem.createFile({ file }); options = { ...this.options, ...options }; const mimeTypes = options.mimeTypes ?? void 0; const maxSize = options.maxSize ?? 10; 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}`); } if (file.size === 0) file = this.fileSystem.createFile({ arrayBuffer: await file.arrayBuffer(), name: file.name, type: file.type }); 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. */ async delete(fileId, skipHook = false) { 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. */ async deleteMany(fileIds, skipHook = false) { 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. */ async exists(fileId) { 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. */ async list() { return this.provider.list(this.name); } /** * Downloads a file from the bucket. */ async download(fileId) { const file = await this.provider.download(this.name, fileId); await this.alepha.events.emit("bucket:file:downloaded", { id: fileId, bucket: this, file }); return file; } $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; //#endregion //#region ../../src/bucket/providers/R2FileStorageProvider.ts /** * Cloudflare R2 storage provider. * * Uses a single R2 bucket binding for all $bucket primitives. * Files are organized as: {APP_NAME}/{bucketName}/{fileId} * * **Required environment variables:** * - `R2_BUCKET_NAME` - The actual R2 bucket name in Cloudflare * * **Optional (uses core Alepha env):** * - `APP_NAME` - Prefix for all files (for multi-app setups sharing one R2 bucket) * * @example * ```bash * # .env * APP_NAME=myapp # optional, used as prefix * R2_BUCKET_NAME=storage * ``` * * @example * ```toml * # wrangler.toml - automatically generated by alepha build * [[r2_buckets]] * binding = "R2" * bucket_name = "storage" * ``` * * @example * ```ts * // Define buckets with validation rules * const avatars = $bucket({ * name: "avatars", * maxFileSize: 5 * 1024 * 1024, * allowedMimeTypes: ["image/*"], * }); * * const documents = $bucket({ * name: "documents", * maxFileSize: 50 * 1024 * 1024, * allowedMimeTypes: ["application/pdf"], * }); * * // Files stored at: myapp/avatars/uuid.png, myapp/documents/uuid.pdf * ``` */ var R2FileStorageProvider = class { alepha = $inject(Alepha); log = $logger(); crypto = $inject(CryptoProvider); env = $env(z.object({ /** * The actual R2 bucket name in Cloudflare. */ R2_BUCKET_NAME: z.string().describe("R2 bucket name in Cloudflare") })); r2; /** * Get the R2 bucket name from environment. */ get bucketName() { return this.env.R2_BUCKET_NAME; } /** * Get the optional prefix from APP_NAME environment variable. * Used for multi-app setups sharing the same R2 bucket. */ get prefix() { return this.alepha.env.APP_NAME; } onStart = $hook({ on: "start", handler: async () => { const cloudflareEnv = this.alepha.get("cloudflare.env"); if (!cloudflareEnv) throw new AlephaError("Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'."); const binding = cloudflareEnv[this.bucketName]; if (!binding) throw new AlephaError(`R2 binding '${this.bucketName}' not found in Cloudflare Workers environment.`); this.r2 = binding; const prefixStr = this.prefix ? `${this.prefix}/` : ""; this.log.info(`R2 storage ready (bucket: ${this.bucketName}, prefix: ${prefixStr || "(none)"})`); for (const bucket of this.alepha.primitives($bucket)) { if (bucket.provider !== this) continue; this.log.debug(`Bucket '${bucket.name}' -> ${prefixStr}${bucket.name}/`); } } }); async upload(bucketName, file, fileId) { const r2 = this.getR2(); fileId ??= this.createId(file.name); const key = this.key(bucketName, fileId); this.log.trace(`Uploading '${key}'`); const arrayBuffer = await file.arrayBuffer(); await r2.put(key, arrayBuffer, { httpMetadata: { contentType: file.type }, customMetadata: { originalName: file.name, bucket: bucketName } }); return fileId; } async download(bucketName, fileId) { const r2 = this.getR2(); const key = this.key(bucketName, fileId); this.log.trace(`Downloading '${key}'`); const object = await r2.get(key); if (!object) throw new FileNotFoundError(`File '${fileId}' not found in bucket '${bucketName}'.`); return { name: object.customMetadata?.originalName ?? fileId, type: object.httpMetadata?.contentType ?? "application/octet-stream", size: object.size, lastModified: object.uploaded.getTime(), stream: () => object.body, arrayBuffer: () => object.arrayBuffer(), text: () => object.text() }; } async exists(bucketName, fileId) { const r2 = this.getR2(); const key = this.key(bucketName, fileId); this.log.trace(`Checking '${key}'`); return await r2.head(key) !== null; } async delete(bucketName, fileId) { const r2 = this.getR2(); const key = this.key(bucketName, fileId); this.log.trace(`Deleting '${key}'`); if (!await this.exists(bucketName, fileId)) throw new FileNotFoundError(`File '${fileId}' not found in bucket '${bucketName}'.`); await r2.delete(key); } async deleteMany(bucketName, fileIds) { if (fileIds.length === 0) return; const r2 = this.getR2(); const keys = fileIds.map((id) => this.key(bucketName, id)); this.log.trace(`Deleting ${keys.length} keys from '${bucketName}'`); for (let i = 0; i < keys.length; i += 1e3) await r2.delete(keys.slice(i, i + 1e3)); } async list(bucketName) { const r2 = this.getR2(); const keyPrefix = this.key(bucketName, ""); this.log.trace(`Listing files in '${keyPrefix}'`); return (await r2.list({ prefix: keyPrefix })).objects.map((object) => object.key.slice(keyPrefix.length)); } /** * Build the full R2 key: {prefix}/{tenantId}/{bucketName}/{fileId} * * When a tenant is active on the current request/job (`currentTenantAtom`), * its id is inserted as a directory so a pooled multi-tenant worker keeps * each tenant's objects isolated. No tenant → the historical * `{prefix}/{bucketName}/{fileId}` layout is unchanged. */ key(bucketName, fileId) { const parts = [bucketName, fileId]; const tenantId = this.alepha.store.get(currentTenantAtom)?.id; if (tenantId) parts.unshift(tenantId); if (this.prefix) parts.unshift(this.prefix); return parts.join("/"); } getR2() { if (!this.r2) throw new AlephaError("R2 storage not initialized. Call start() first."); return this.r2; } createId(filename) { const ext = filename.includes(".") ? filename.split(".").pop() : ""; const id = this.crypto.randomUUID(); return ext ? `${id}.${ext}` : id; } }; //#endregion //#region ../../src/bucket/providers/S3FileStorageProvider.ts const envSchema = z.object({ /** * S3 endpoint URL. The bucket name is appended (path-style) per request. * * Examples: * - AWS S3: `https://s3.us-east-1.amazonaws.com` * - Cloudflare R2: `https://<account-id>.r2.cloudflarestorage.com` * - MinIO: `http://localhost:9000` * - DigitalOcean Spaces: `https://<region>.digitaloceanspaces.com` */ S3_ENDPOINT: z.string(), /** * AWS region or "auto" for R2. * * @default "auto" */ S3_REGION: z.string().optional(), /** * Access key ID for S3 authentication. */ S3_ACCESS_KEY_ID: z.string(), /** * Secret access key for S3 authentication. */ S3_SECRET_ACCESS_KEY: z.string() }); /** * S3-compatible file storage provider for Node.js. * * Backed by `s3mini` (zero-dep, ~20 KB). Works with AWS S3, Cloudflare R2, * MinIO, DigitalOcean Spaces, Backblaze B2, and any other S3-compatible service. * * Uses path-style addressing (`<endpoint>/<bucket>`). */ var S3FileStorageProvider = class { log = $logger(); env = $env(envSchema); alepha = $inject(Alepha); fileSystem = $inject(FileSystemProvider); fileDetector = $inject(FileDetector); crypto = $inject(CryptoProvider); clients = /* @__PURE__ */ new Map(); /** * Convert bucket name to S3-compatible format. * S3 bucket names must be lowercase, 3-63 characters, no underscores. */ convertName(name) { return name.replaceAll("/", "-").replaceAll("_", "-").toLowerCase(); } getClient(bucketName) { const name = this.convertName(bucketName); let client = this.clients.get(name); if (!client) { const endpoint = this.env.S3_ENDPOINT.replace(/\/+$/, ""); client = new S3mini({ accessKeyId: this.env.S3_ACCESS_KEY_ID, secretAccessKey: this.env.S3_SECRET_ACCESS_KEY, region: this.env.S3_REGION || "auto", endpoint: `${endpoint}/${name}` }); this.clients.set(name, client); } return client; } onStart = $hook({ on: "start", handler: async () => { for (const bucket of this.alepha.primitives($bucket)) { if (bucket.provider !== this) continue; const name = this.convertName(bucket.name); const client = this.getClient(bucket.name); this.log.debug(`Preparing S3 bucket '${name}'...`); if (!await client.bucketExists()) { this.log.debug(`Creating S3 bucket '${name}'...`); if (!await client.createBucket()) throw new AlephaError(`Failed to create S3 bucket '${name}'`); } this.log.info(`S3 bucket '${bucket.name}' OK`); } } }); /** * Object key, tenant-scoped when a tenant is active (`currentTenantAtom`): * `{tenantId}/{fileId}` within the per-bucket S3 bucket. No tenant → `{fileId}`. */ key(fileId) { const tenantId = this.alepha.store.get(currentTenantAtom)?.id; return tenantId ? `${tenantId}/${fileId}` : fileId; } createId(mimeType) { const ext = this.fileDetector.getExtensionFromMimeType(mimeType); return `${this.crypto.randomUUID()}.${ext}`; } async upload(bucketName, file, fileId) { fileId ??= this.createId(file.type); this.log.trace(`Uploading file '${file.name}' to bucket '${bucketName}' with id '${fileId}'...`); const client = this.getClient(bucketName); try { const buffer = new Uint8Array(await file.arrayBuffer()); await client.putObject(this.key(fileId), buffer, file.type || "application/octet-stream", void 0, { "x-amz-meta-name": encodeURIComponent(file.name) }, file.size); this.log.trace(`File uploaded successfully: ${fileId}`); return fileId; } catch (error) { this.log.error(`Failed to upload file: ${error}`); if (error instanceof Error) throw new AlephaError(`Upload failed: ${error.message}`, { cause: error }); throw error; } } async download(bucketName, fileId) { this.log.trace(`Downloading file '${fileId}' from bucket '${bucketName}'...`); const response = await this.getClient(bucketName).getObjectResponse(this.key(fileId)); if (!response) throw new FileNotFoundError(`File '${fileId}' not found in bucket '${bucketName}'`); const mimeType = response.headers.get("content-type") || this.fileDetector.getContentType(fileId); const metaName = response.headers.get("x-amz-meta-name"); const name = metaName ? decodeURIComponent(metaName) : fileId; const contentLength = response.headers.get("content-length"); const size = contentLength ? Number.parseInt(contentLength, 10) : 0; if (!response.body) return this.fileSystem.createFile({ buffer: Buffer$1.alloc(0), name, type: mimeType }); return this.fileSystem.createFile({ stream: Readable.fromWeb(response.body), name, type: mimeType, size }); } async exists(bucketName, fileId) { this.log.trace(`Checking existence of file '${fileId}' in bucket '${bucketName}'...`); return await this.getClient(bucketName).objectExists(this.key(fileId)) === true; } async delete(bucketName, fileId) { this.log.trace(`Deleting file '${fileId}' from bucket '${bucketName}'...`); const client = this.getClient(bucketName); try { await client.deleteObject(this.key(fileId)); } catch (error) { this.log.error(`Failed to delete file: ${error}`); if (error instanceof Error) throw new FileNotFoundError("Error deleting file", { cause: error }); throw error; } } async list(bucketName) { this.log.trace(`Listing files in bucket '${bucketName}'...`); const client = this.getClient(bucketName); const tenantId = this.alepha.store.get(currentTenantAtom)?.id; const prefix = tenantId ? `${tenantId}/` : void 0; const objects = await client.listObjects(void 0, prefix); if (!objects) return []; return objects.map((object) => prefix && object.Key.startsWith(prefix) ? object.Key.slice(prefix.length) : object.Key); } async deleteMany(bucketName, fileIds) { if (fileIds.length === 0) return; this.log.trace(`Deleting ${fileIds.length} files from bucket '${bucketName}'...`); const client = this.getClient(bucketName); for (let i = 0; i < fileIds.length; i += 1e3) { const keys = fileIds.slice(i, i + 1e3).map((id) => this.key(id)); try { const batch = client.deleteObjects; if (typeof batch === "function") await batch.call(client, keys); else await Promise.all(keys.map((key) => client.deleteObject(key))); } catch (error) { this.log.error(`Failed to delete files: ${error}`); if (error instanceof Error) throw new FileNotFoundError("Error deleting files", { cause: error }); throw error; } } } }; //#endregion //#region ../../src/bucket/index.workerd.ts const AlephaBucket = $module({ name: "alepha.bucket", primitives: [$bucket], services: [ FileStorageProvider, MemoryFileStorageProvider, R2FileStorageProvider ], variants: [ MemoryFileStorageProvider, R2FileStorageProvider, S3FileStorageProvider ], register: (alepha) => { alepha.with({ optional: true, provide: FileStorageProvider, use: alepha.isTest() ? MemoryFileStorageProvider : R2FileStorageProvider }); } }); //#endregion export { $bucket, AlephaBucket, BucketPrimitive, FileNotFoundError, FileStorageProvider, MemoryFileStorageProvider, R2FileStorageProvider, S3FileStorageProvider }; //# sourceMappingURL=index.workerd.js.map