UNPKG

alepha

Version:

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

271 lines (238 loc) 7.8 kB
import type { R2Bucket } from "@cloudflare/workers-types"; import { $env, $hook, $inject, Alepha, AlephaError, type FileLike, z, } from "alepha"; import { CryptoProvider } from "alepha/crypto"; import { $logger } from "alepha/logger"; import { currentTenantAtom } from "alepha/security"; import { FileNotFoundError } from "../errors/FileNotFoundError.ts"; import { $bucket } from "../primitives/$bucket.ts"; import type { FileStorageProvider } from "./FileStorageProvider.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 * ``` */ export class R2FileStorageProvider implements FileStorageProvider { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly crypto = $inject(CryptoProvider); protected readonly env = $env( z.object({ /** * The actual R2 bucket name in Cloudflare. */ R2_BUCKET_NAME: z.string().describe("R2 bucket name in Cloudflare"), }), ); protected r2?: R2Bucket; /** * Get the R2 bucket name from environment. */ public get bucketName(): string { 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. */ public get prefix(): string | undefined { return this.alepha.env.APP_NAME; } protected readonly onStart = $hook({ on: "start", handler: async () => { const cloudflareEnv = this.alepha.get("cloudflare.env") as | Record<string, unknown> | undefined; if (!cloudflareEnv) { throw new AlephaError( "Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'.", ); } const binding = cloudflareEnv[this.bucketName] as R2Bucket | undefined; 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}/`, ); } }, }); public async upload( bucketName: string, file: FileLike, fileId?: string, ): Promise<string> { 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; } public async download(bucketName: string, fileId: string): Promise<FileLike> { 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}'.`, ); } const originalName = object.customMetadata?.originalName ?? fileId; const contentType = object.httpMetadata?.contentType ?? "application/octet-stream"; return { name: originalName, type: contentType, size: object.size, lastModified: object.uploaded.getTime(), stream: () => object.body as unknown as ReadableStream, arrayBuffer: () => object.arrayBuffer(), text: () => object.text(), }; } public async exists(bucketName: string, fileId: string): Promise<boolean> { const r2 = this.getR2(); const key = this.key(bucketName, fileId); this.log.trace(`Checking '${key}'`); const object = await r2.head(key); return object !== null; } public async delete(bucketName: string, fileId: string): Promise<void> { const r2 = this.getR2(); const key = this.key(bucketName, fileId); this.log.trace(`Deleting '${key}'`); // R2's delete doesn't throw if the file doesn't exist, // so we check existence first for consistency with other providers const exists = await this.exists(bucketName, fileId); if (!exists) { throw new FileNotFoundError( `File '${fileId}' not found in bucket '${bucketName}'.`, ); } await r2.delete(key); } public async deleteMany( bucketName: string, fileIds: string[], ): Promise<void> { 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}'`); // R2 binding accepts a string[] and silently ignores missing keys. // Chunk at 1000 to stay within the documented batch limit. for (let i = 0; i < keys.length; i += 1000) { await r2.delete(keys.slice(i, i + 1000)); } } public async list(bucketName: string): Promise<string[]> { const r2 = this.getR2(); const keyPrefix = this.key(bucketName, ""); this.log.trace(`Listing files in '${keyPrefix}'`); // Flat, single-page listing (~1000 keys). Not a search API. const listed = await r2.list({ prefix: keyPrefix }); return listed.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. */ protected key(bucketName: string, fileId: string): string { 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("/"); } protected getR2(): R2Bucket { if (!this.r2) { throw new AlephaError("R2 storage not initialized. Call start() first."); } return this.r2; } protected createId(filename: string): string { const ext = filename.includes(".") ? filename.split(".").pop() : ""; const id = this.crypto.randomUUID(); return ext ? `${id}.${ext}` : id; } }