diff --git a/apps/server/src/storage/blob.ts b/apps/server/src/storage/blob.ts index 05a3810..8779f85 100644 --- a/apps/server/src/storage/blob.ts +++ b/apps/server/src/storage/blob.ts @@ -2,10 +2,10 @@ import { AwsClient } from "aws4fetch"; import type { Context } from "hono"; import type { Env } from "../types/env"; import { ErrorCode, isStorageError } from "../types/error"; +import type { BucketProvider } from "./bucket"; import type { CacheProvider } from "./cache"; import { createStorageError } from "./storage"; - -export interface BlobStorageProvider { +export interface IBlobStorageProvider { addBlob(blobId: string, data: ArrayBuffer, size: number): Promise; getBlobUrl(path: string): Promise; removeBlob(path: string): Promise; @@ -13,8 +13,7 @@ export interface BlobStorageProvider { deletePath(prefix: string): Promise; } -export class R2BlobStorageProvider implements BlobStorageProvider { - private readonly storage: R2Bucket; +export class BlobStorageProvider implements IBlobStorageProvider { private readonly aws: AwsClient; private readonly accountId: string; private readonly bucketName: string; @@ -25,8 +24,8 @@ export class R2BlobStorageProvider implements BlobStorageProvider { constructor( private readonly ctx: Context, private readonly cache: CacheProvider, + private readonly objectStorage: BucketProvider, ) { - this.storage = ctx.env.STORAGE_BUCKET; this.accountId = ctx.env.ACCOUNT_ID; this.bucketName = ctx.env.R2_BUCKET_NAME; this.aws = new AwsClient({ @@ -41,7 +40,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider { size: number, ): Promise { try { - await this.storage.put(blobId, data, { + await this.objectStorage.put(blobId, data, { customMetadata: { size: size.toString(), }, @@ -64,14 +63,16 @@ export class R2BlobStorageProvider implements BlobStorageProvider { } try { - const object = await this.storage.head(path); + const object = await this.objectStorage.head(path); if (!object) { throw createStorageError(ErrorCode.NotFound, "Blob not found"); } // Construct URL for the R2 object - const url = new URL( - `https://${this.bucketName}.${this.accountId}.r2.cloudflarestorage.com/${path}`, + const url = this.objectStorage.buildUrl( + this.bucketName, + path, + this.accountId, ); // Set expiration to 1 hour (3600 seconds) @@ -103,7 +104,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider { async removeBlob(path: string): Promise { try { - await this.storage.delete(path); + await this.objectStorage.delete(path); } catch (error) { console.error("Error removing blob:", error); throw createStorageError( @@ -119,13 +120,13 @@ export class R2BlobStorageProvider implements BlobStorageProvider { ? sourcePath.split("?")[0].split("/").pop() : sourcePath; const actualSourcePath = parsedSrcPath ?? ""; - const sourceObject = await this.storage.get(actualSourcePath); + const sourceObject = await this.objectStorage.get(actualSourcePath); if (!sourceObject) { throw createStorageError(ErrorCode.NotFound, "Source blob not found"); } // Copy to new location - await this.storage.put( + await this.objectStorage.put( destinationPath, await sourceObject.arrayBuffer(), { @@ -134,7 +135,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider { ); // Delete original - await this.storage.delete(sourcePath); + await this.objectStorage.delete(sourcePath); // Return URL for new location return await this.getBlobUrl(destinationPath); @@ -149,7 +150,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider { async deletePath(prefix: string): Promise { try { - const objects = await this.storage.list({ + const objects = await this.objectStorage.list({ prefix, }); @@ -181,7 +182,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider { private async deleteObjects(keys: string[]): Promise { try { - await Promise.all(keys.map((key) => this.storage.delete(key))); + await Promise.all(keys.map((key) => this.objectStorage.delete(key))); } catch (error) { console.error("Error deleting objects:", error); throw createStorageError( diff --git a/apps/server/src/storage/bucket.ts b/apps/server/src/storage/bucket.ts new file mode 100644 index 0000000..07e2993 --- /dev/null +++ b/apps/server/src/storage/bucket.ts @@ -0,0 +1,63 @@ +export interface BucketProvider { + head(key: string): Promise; + get(key: string): Promise; + list(options: { prefix: string }): Promise; + delete(key: string): Promise; + put( + key: string, + value: ArrayBuffer, + options: { + customMetadata: Record | undefined; + }, + ): Promise; + buildUrl(bucketName: string, path: string, accountId: string): URL; +} + +interface BucketObject { + key: string; +} + +interface BucketObjectBody { + customMetadata?: Record; + arrayBuffer(): Promise; +} + +interface BucketObjects { + objects: BucketObject[]; +} + +export class R2BucketProvider implements BucketProvider { + constructor(private readonly bucket: R2Bucket) {} + + async get(key: string) { + return this.bucket.get(key); + } + + async list(options: { prefix: string }): Promise { + return this.bucket.list(options); + } + + async delete(keys: string[] | string): Promise { + return this.bucket.delete(keys); + } + + async put( + key: string, + value: ArrayBuffer, + options: { + customMetadata: Record; + }, + ): Promise { + return await this.bucket.put(key, value, options); + } + + async head(key: string): Promise { + return await this.bucket.head(key); + } + + buildUrl(bucketName: string, path: string, accountId: string): URL { + return new URL( + `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${path}`, + ); + } +} diff --git a/apps/server/src/storage/d1.ts b/apps/server/src/storage/d1.ts index adc895d..0e6e710 100644 --- a/apps/server/src/storage/d1.ts +++ b/apps/server/src/storage/d1.ts @@ -16,7 +16,7 @@ import type { PackageHashToBlobInfoMap, } from "../types/schemas"; import { generateKey } from "../utils/security"; -import type { BlobStorageProvider } from "./blob"; +import type { IBlobStorageProvider } from "./blob"; import type { CacheProvider } from "./cache"; import { type StorageProvider, createStorageError } from "./storage"; @@ -32,7 +32,7 @@ export class D1StorageProvider implements StorageProvider { constructor( private readonly ctx: Context, private readonly cache: CacheProvider, - private readonly blob: BlobStorageProvider, + private readonly blob: IBlobStorageProvider, ) { this.db = drizzle(this.ctx.env.DB, { schema }); } diff --git a/apps/server/src/storage/factory.ts b/apps/server/src/storage/factory.ts index 655fe30..60baf8e 100644 --- a/apps/server/src/storage/factory.ts +++ b/apps/server/src/storage/factory.ts @@ -1,7 +1,7 @@ import type { Context } from "hono"; import type { Env } from "../types/env"; -import type { BlobStorageProvider } from "./blob"; -import { R2BlobStorageProvider } from "./blob"; +import { BlobStorageProvider, type IBlobStorageProvider } from "./blob"; +import { type BucketProvider, R2BucketProvider } from "./bucket"; import { type CacheProvider, InMemoryCacheProvider } from "./cache"; import { D1StorageProvider } from "./d1"; import type { StorageProvider } from "./storage"; @@ -9,7 +9,8 @@ import type { StorageProvider } from "./storage"; let storageInstance: StorageProvider | null = null; let lastContext: Context | null = null; let cacheInstance: CacheProvider | null = null; -let blobInstance: BlobStorageProvider | null = null; +let blobInstance: IBlobStorageProvider | null = null; +let objectStorageInstance: BucketProvider | null = null; export function getStorageProvider(ctx: Context): StorageProvider { // If context changed or no instance exists, create new instance @@ -29,12 +30,20 @@ export function getCacheProvider(ctx: Context): CacheProvider { return cacheInstance; } +export function getObjectStorageProvider(ctx: Context): BucketProvider { + if (!objectStorageInstance) { + objectStorageInstance = new R2BucketProvider(ctx.env.STORAGE_BUCKET); + } + return objectStorageInstance; +} + export function getBlobProvider( ctx: Context, cache: CacheProvider, -): BlobStorageProvider { +): IBlobStorageProvider { if (!blobInstance || ctx !== lastContext) { - blobInstance = new R2BlobStorageProvider(ctx, cache); + const objectStorage = getObjectStorageProvider(ctx); + blobInstance = new BlobStorageProvider(ctx, cache, objectStorage); } return blobInstance; } diff --git a/apps/server/test/storage/mock-blob.ts b/apps/server/test/storage/mock-blob.ts index 93aaed6..c94e5c6 100644 --- a/apps/server/test/storage/mock-blob.ts +++ b/apps/server/test/storage/mock-blob.ts @@ -1,9 +1,9 @@ import { Context } from "hono"; -import type { BlobStorageProvider } from "../../src/storage/blob"; +import type { IBlobStorageProvider } from "../../src/storage/blob"; import type { CacheProvider } from "../../src/storage/cache"; -import { Env } from "../../src/types/env"; +import type { Env } from "../../src/types/env"; -export class MockBlobStorageProvider implements BlobStorageProvider { +export class MockBlobStorageProvider implements IBlobStorageProvider { private readonly store = new Map(); private readonly urls = new Map();