alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
271 lines (238 loc) • 7.8 kB
text/typescript
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;
}
}