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
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"build": "wrangler build"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.787.0",
"@hono/zod-openapi": "^0.18.3",
"@hono/zod-validator": "^0.4.2",
"@tsndr/cloudflare-worker-jwt": "^3.1.3",
Expand Down
13 changes: 7 additions & 6 deletions apps/server/src/storage/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export class BlobStorageProvider implements IBlobStorageProvider {
this.accountId = ctx.env.ACCOUNT_ID;
this.bucketName = ctx.env.R2_BUCKET_NAME;
this.aws = new AwsClient({
accessKeyId: ctx.env.R2_ACCESS_KEY_ID,
secretAccessKey: ctx.env.R2_SECRET_ACCESS_KEY,
accessKeyId: ctx.env.AWS_ACCESS_KEY_ID,
secretAccessKey: ctx.env.AWS_SECRET_ACCESS_KEY,
});
}

Expand Down Expand Up @@ -63,10 +63,11 @@ export class BlobStorageProvider implements IBlobStorageProvider {
}

try {
const object = await this.objectStorage.head(path);
if (!object) {
throw createStorageError(ErrorCode.NotFound, "Blob not found");
}
// const object = await this.objectStorage.head(path);
// if (!object) {
// return null;
// throw createStorageError(ErrorCode.NotFound, "Blob not found");
// }

// Construct URL for the R2 object
const url = this.objectStorage.buildUrl(
Expand Down
50 changes: 8 additions & 42 deletions apps/server/src/storage/bucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,62 +2,28 @@ 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>;
delete(keys: string[] | string): Promise<void>;
put(
key: string,
value: ArrayBuffer,
options: {
customMetadata: Record<string, string> | undefined;
customMetadata?: Record<string, string>;
},
): Promise<BucketObject>;
buildUrl(bucketName: string, path: string, accountId: string): URL;
}

interface BucketObject {
export interface BucketObject {
key: string;
size: number;
lastModified?: Date;
customMetadata?: Record<string, string>;
}

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

interface BucketObjects {
export 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}`,
);
}
}
10 changes: 8 additions & 2 deletions apps/server/src/storage/factory.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Context } from "hono";
import type { Env } from "../types/env";
import { BlobStorageProvider, type IBlobStorageProvider } from "./blob";
import { type BucketProvider, R2BucketProvider } from "./bucket";
import type { BucketProvider } from "./bucket";
import { type CacheProvider, InMemoryCacheProvider } from "./cache";
import { D1StorageProvider } from "./d1";
import { S3BucketProvider } from "./s3";
import type { StorageProvider } from "./storage";

let storageInstance: StorageProvider | null = null;
Expand Down Expand Up @@ -32,7 +33,12 @@ export function getCacheProvider(ctx: Context<Env>): CacheProvider {

export function getObjectStorageProvider(ctx: Context<Env>): BucketProvider {
if (!objectStorageInstance) {
objectStorageInstance = new R2BucketProvider(ctx.env.STORAGE_BUCKET);
objectStorageInstance = new S3BucketProvider(
ctx.env.AWS_REGION,
ctx.env.AWS_ACCESS_KEY_ID,
ctx.env.AWS_SECRET_ACCESS_KEY,
ctx.env.AWS_S3_BUCKET_NAME,
);
}
return objectStorageInstance;
}
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/storage/r2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { BucketObject, BucketObjects, BucketProvider } from "./bucket";

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}`,
);
}
}
158 changes: 158 additions & 0 deletions apps/server/src/storage/s3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import {
DeleteObjectCommand,
DeleteObjectsCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type {
BucketObject,
BucketObjectBody,
BucketObjects,
BucketProvider,
} from "./bucket";

export class S3BucketProvider implements BucketProvider {
private readonly s3: S3Client;
private readonly bucketName: string;
private readonly region: string;

constructor(
region: string,
accessKeyId: string,
secretAccessKey: string,
bucketName: string,
) {
this.region = region;
this.bucketName = bucketName;
this.s3 = new S3Client({
region,
credentials: {
accessKeyId,
secretAccessKey,
},
});
}

private getEndpoint(path = ""): string {
return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${path}`;
}

async get(key: string): Promise<BucketObjectBody | null> {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.s3.send(command);
if (!response.Body) return null;

return {
key,
size: response.ContentLength || 0,
lastModified: response.LastModified || new Date(),
customMetadata: response.Metadata || {},
arrayBuffer: async () => {
const body = response.Body as unknown as {
transformToByteArray: () => Promise<Uint8Array>;
};
const bytes = await body.transformToByteArray();
return bytes.buffer as ArrayBuffer;
},
};
} catch (error) {
return null;
}
}

async list(options: { prefix: string }): Promise<BucketObjects> {
try {
const command = new ListObjectsV2Command({
Bucket: this.bucketName,
Prefix: options.prefix,
});
const response = await this.s3.send(command);

return {
objects: (response.Contents || []).map((item) => ({
key: item.Key || "",
size: item.Size || 0,
lastModified: item.LastModified || new Date(),
customMetadata: {},
})),
};
} catch (error) {
return { objects: [] };
}
}

async delete(keys: string[] | string): Promise<void> {
const keyArray = Array.isArray(keys) ? keys : [keys];

if (keyArray.length === 1) {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: keyArray[0],
});
await this.s3.send(command);
return;
}

const command = new DeleteObjectsCommand({
Bucket: this.bucketName,
Delete: {
Objects: keyArray.map((key) => ({ Key: key })),
},
});
await this.s3.send(command);
}

async put(
key: string,
value: ArrayBuffer,
options: {
customMetadata: Record<string, string>;
},
): Promise<BucketObject> {
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: new Uint8Array(value),
Metadata: options.customMetadata,
});
const response = await this.s3.send(command);
console.log(response, key);

return {
key,
size: value.byteLength,
lastModified: new Date(),
customMetadata: options.customMetadata,
};
}

async head(key: string): Promise<BucketObject | null> {
try {
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
});
const response = await this.s3.send(command);

return {
key,
size: response.ContentLength || 0,
lastModified: response.LastModified || new Date(),
customMetadata: response.Metadata || {},
};
} catch (error) {
return null;
}
}

buildUrl(_bucketName: string, path: string, _accountId: string): URL {
return new URL(this.getEndpoint(path));
}
}
4 changes: 4 additions & 0 deletions apps/server/src/types/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@ export interface Env {
R2_BUCKET_NAME: string;
R2_ACCESS_KEY_ID: string;
R2_SECRET_ACCESS_KEY: string;
AWS_REGION: string;
AWS_ACCESS_KEY_ID: string;
AWS_SECRET_ACCESS_KEY: string;
AWS_S3_BUCKET_NAME: string;
};
}
1 change: 1 addition & 0 deletions apps/server/test/routes/management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1798,3 +1798,4 @@ describe("Management Routes", () => {
});
});
});

9 changes: 7 additions & 2 deletions apps/server/test/storage/d1.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
createTestApp,
createTestDeployment,
createTestPackage,
getMockBucketProvider,
} from "../utils/fixtures";
import { env } from "cloudflare:test";
import { MockBlobStorageProvider } from "./mock-blob";
Expand Down Expand Up @@ -48,9 +49,13 @@ describe("D1StorageProvider Cache", () => {
} as unknown as Context<Env>;

mockCache = new InMemoryCacheProvider();
mockBlob = new MockBlobStorageProvider(mockCtx, mockCache);
const mockBucketProvider = getMockBucketProvider();
mockBlob = new MockBlobStorageProvider(
mockCtx,
mockCache,
mockBucketProvider,
);
storage = new D1StorageProvider(mockCtx, mockCache, mockBlob);
console.log(typeof mockCtx.env.DB.prepare);
});

afterEach(async () => {
Expand Down
Loading