Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 16 additions & 15 deletions apps/server/src/storage/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@ 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<string>;
getBlobUrl(path: string): Promise<string>;
removeBlob(path: string): Promise<void>;
moveBlob(sourcePath: string, destinationPath: string): Promise<string>;
deletePath(prefix: string): Promise<void>;
}

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;
Expand All @@ -25,8 +24,8 @@ export class R2BlobStorageProvider implements BlobStorageProvider {
constructor(
private readonly ctx: Context<Env>,
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({
Expand All @@ -41,7 +40,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider {
size: number,
): Promise<string> {
try {
await this.storage.put(blobId, data, {
await this.objectStorage.put(blobId, data, {
customMetadata: {
size: size.toString(),
},
Expand All @@ -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)
Expand Down Expand Up @@ -103,7 +104,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider {

async removeBlob(path: string): Promise<void> {
try {
await this.storage.delete(path);
await this.objectStorage.delete(path);
} catch (error) {
console.error("Error removing blob:", error);
throw createStorageError(
Expand All @@ -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(),
{
Expand All @@ -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);
Expand All @@ -149,7 +150,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider {

async deletePath(prefix: string): Promise<void> {
try {
const objects = await this.storage.list({
const objects = await this.objectStorage.list({
prefix,
});

Expand Down Expand Up @@ -181,7 +182,7 @@ export class R2BlobStorageProvider implements BlobStorageProvider {

private async deleteObjects(keys: string[]): Promise<void> {
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(
Expand Down
63 changes: 63 additions & 0 deletions apps/server/src/storage/bucket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
export interface BucketProvider {
head(key: string): Promise<BucketObject | null>;
get(key: string): Promise<BucketObjectBody | null>;
list(options: { prefix: string }): Promise<BucketObjects>;
delete(key: string): Promise<void>;
put(
key: string,
value: ArrayBuffer,
options: {
customMetadata: Record<string, string> | undefined;
},
): Promise<BucketObject>;
buildUrl(bucketName: string, path: string, accountId: string): URL;
}

interface BucketObject {
key: string;
}

interface BucketObjectBody {
customMetadata?: Record<string, string>;
arrayBuffer(): Promise<ArrayBuffer>;
}

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<BucketObjects> {
return this.bucket.list(options);
}

async delete(keys: string[] | string): Promise<void> {
return this.bucket.delete(keys);
}

async put(
key: string,
value: ArrayBuffer,
options: {
customMetadata: Record<string, string>;
},
): Promise<BucketObject> {
return await this.bucket.put(key, value, options);
}

async head(key: string): Promise<BucketObject | null> {
return await this.bucket.head(key);
}

buildUrl(bucketName: string, path: string, accountId: string): URL {
return new URL(
`https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${path}`,
);
}
}
4 changes: 2 additions & 2 deletions apps/server/src/storage/d1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -32,7 +32,7 @@ export class D1StorageProvider implements StorageProvider {
constructor(
private readonly ctx: Context<Env>,
private readonly cache: CacheProvider,
private readonly blob: BlobStorageProvider,
private readonly blob: IBlobStorageProvider,
) {
this.db = drizzle(this.ctx.env.DB, { schema });
}
Expand Down
19 changes: 14 additions & 5 deletions apps/server/src/storage/factory.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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";

let storageInstance: StorageProvider | null = null;
let lastContext: Context<Env> | 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<Env>): StorageProvider {
// If context changed or no instance exists, create new instance
Expand All @@ -29,12 +30,20 @@ export function getCacheProvider(ctx: Context<Env>): CacheProvider {
return cacheInstance;
}

export function getObjectStorageProvider(ctx: Context<Env>): BucketProvider {
if (!objectStorageInstance) {
objectStorageInstance = new R2BucketProvider(ctx.env.STORAGE_BUCKET);
}
return objectStorageInstance;
}

export function getBlobProvider(
ctx: Context<Env>,
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;
}
6 changes: 3 additions & 3 deletions apps/server/test/storage/mock-blob.ts
Original file line number Diff line number Diff line change
@@ -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<string, ArrayBuffer>();
private readonly urls = new Map<string, string>();

Expand Down