alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
595 lines (594 loc) • 20.2 kB
JavaScript
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