UNPKG

alepha

Version:

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

294 lines (258 loc) 9.19 kB
import { Buffer } from "node:buffer"; import { Readable } from "node:stream"; import type { ReadableStream as NodeWebStream } from "node:stream/web"; import { $env, $hook, $inject, Alepha, AlephaError, type FileLike, type Static, z, } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { $logger } from "alepha/logger"; import { currentTenantAtom } from "alepha/security"; import { FileDetector, FileSystemProvider } from "alepha/system"; import { S3mini } from "s3mini"; import { FileNotFoundError } from "../errors/FileNotFoundError.ts"; import { $bucket } from "../primitives/$bucket.ts"; import type { FileStorageProvider } from "./FileStorageProvider.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(), }); declare module "alepha" { interface Env extends Partial<Static<typeof envSchema>> {} } /** * 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>`). */ export class S3FileStorageProvider implements FileStorageProvider { protected readonly log = $logger(); protected readonly env = $env(envSchema); protected readonly alepha = $inject(Alepha); protected readonly fileSystem = $inject(FileSystemProvider); protected readonly fileDetector = $inject(FileDetector); protected readonly crypto = $inject(CryptoProvider); protected readonly clients: Map<string, S3mini> = new Map(); /** * Convert bucket name to S3-compatible format. * S3 bucket names must be lowercase, 3-63 characters, no underscores. */ public convertName(name: string): string { return name.replaceAll("/", "-").replaceAll("_", "-").toLowerCase(); } protected getClient(bucketName: string): S3mini { 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; } protected readonly 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}'...`); const exists = await client.bucketExists(); if (!exists) { this.log.debug(`Creating S3 bucket '${name}'...`); const created = await client.createBucket(); if (!created) { 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}`. */ protected key(fileId: string): string { const tenantId = this.alepha.store.get(currentTenantAtom)?.id; return tenantId ? `${tenantId}/${fileId}` : fileId; } protected createId(mimeType: string): string { const ext = this.fileDetector.getExtensionFromMimeType(mimeType); return `${this.crypto.randomUUID()}.${ext}`; } public async upload( bucketName: string, file: FileLike, fileId?: string, ): Promise<string> { 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", undefined, { "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; } } public async download(bucketName: string, fileId: string): Promise<FileLike> { this.log.trace( `Downloading file '${fileId}' from bucket '${bucketName}'...`, ); const client = this.getClient(bucketName); const response = await client.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; // Stream the body straight through instead of buffering the whole object // into memory. `response.body` is null only for a zero-byte object. if (!response.body) { return this.fileSystem.createFile({ buffer: Buffer.alloc(0), name, type: mimeType, }); } return this.fileSystem.createFile({ stream: Readable.fromWeb(response.body as unknown as NodeWebStream), name, type: mimeType, size, }); } public async exists(bucketName: string, fileId: string): Promise<boolean> { this.log.trace( `Checking existence of file '${fileId}' in bucket '${bucketName}'...`, ); const client = this.getClient(bucketName); const result = await client.objectExists(this.key(fileId)); return result === true; } public async delete(bucketName: string, fileId: string): Promise<void> { 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; } } public async list(bucketName: string): Promise<string[]> { 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}/` : undefined; // Flat, single-page listing (~1000 keys). Not a search API. const objects = await client.listObjects(undefined, prefix); if (!objects) return []; return objects.map((object) => prefix && object.Key.startsWith(prefix) ? object.Key.slice(prefix.length) : object.Key, ); } public async deleteMany( bucketName: string, fileIds: string[], ): Promise<void> { if (fileIds.length === 0) return; this.log.trace( `Deleting ${fileIds.length} files from bucket '${bucketName}'...`, ); const client = this.getClient(bucketName); // S3 DeleteObjects caps at 1000 keys per request. for (let i = 0; i < fileIds.length; i += 1000) { const keys = fileIds.slice(i, i + 1000).map((id) => this.key(id)); try { // bun:s3 client exposes a per-key deleteObject; some SDKs also expose // deleteObjects(keys: string[]). Prefer batch when available. const batch = ( client as unknown as { deleteObjects?: (keys: string[]) => Promise<unknown>; } ).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; } } } }