mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 20:37:21 +00:00
added r2 binding support to cf adapter
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import { SqliteConnection } from "bknd/data";
|
||||
import { KyselyPluginRunner } from "data";
|
||||
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
|
||||
import type { QB } from "data/connection/Connection";
|
||||
import { SqliteIntrospector } from "data/connection/SqliteIntrospector";
|
||||
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||
import { D1Dialect } from "kysely-d1";
|
||||
|
||||
178
app/src/adapter/cloudflare/StorageR2Adapter.ts
Normal file
178
app/src/adapter/cloudflare/StorageR2Adapter.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { registries } from "bknd";
|
||||
import { isDebug } from "bknd/core";
|
||||
import { StringEnum, Type } from "bknd/utils";
|
||||
import type { FileBody, StorageAdapter } from "media/storage/Storage";
|
||||
import { guess } from "media/storage/mime-types-tiny";
|
||||
import { getBindings } from "./bindings";
|
||||
|
||||
export function makeSchema(bindings: string[] = []) {
|
||||
return Type.Object(
|
||||
{
|
||||
binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String())
|
||||
},
|
||||
{ title: "R2", description: "Cloudflare R2 storage" }
|
||||
);
|
||||
}
|
||||
|
||||
export function registerMedia(env: Record<string, any>) {
|
||||
const r2_bindings = getBindings(env, "R2Bucket");
|
||||
|
||||
registries.media.register(
|
||||
"r2",
|
||||
class extends StorageR2Adapter {
|
||||
constructor(private config: any) {
|
||||
const binding = r2_bindings.find((b) => b.key === config.binding);
|
||||
if (!binding) {
|
||||
throw new Error(`No R2Bucket found with key ${config.binding}`);
|
||||
}
|
||||
|
||||
super(binding?.value);
|
||||
}
|
||||
|
||||
override getSchema() {
|
||||
return makeSchema(r2_bindings.map((b) => b.key));
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
config: this.config
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter for R2 storage
|
||||
* @todo: add tests (bun tests won't work, need node native tests)
|
||||
*/
|
||||
export class StorageR2Adapter implements StorageAdapter {
|
||||
constructor(private readonly bucket: R2Bucket) {}
|
||||
|
||||
getName(): string {
|
||||
return "r2";
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return makeSchema();
|
||||
}
|
||||
|
||||
async putObject(key: string, body: FileBody) {
|
||||
try {
|
||||
const res = await this.bucket.put(key, body);
|
||||
return res?.etag;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
async listObjects(
|
||||
prefix?: string
|
||||
): Promise<{ key: string; last_modified: Date; size: number }[]> {
|
||||
const list = await this.bucket.list({ limit: 50 });
|
||||
return list.objects.map((item) => ({
|
||||
key: item.key,
|
||||
size: item.size,
|
||||
last_modified: item.uploaded
|
||||
}));
|
||||
}
|
||||
|
||||
private async headObject(key: string): Promise<R2Object | null> {
|
||||
return await this.bucket.head(key);
|
||||
}
|
||||
|
||||
async objectExists(key: string): Promise<boolean> {
|
||||
return (await this.headObject(key)) !== null;
|
||||
}
|
||||
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
let object: R2ObjectBody | null;
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": guess(key)
|
||||
});
|
||||
|
||||
//console.log("getObject:headers", headersToObject(headers));
|
||||
if (headers.has("range")) {
|
||||
const options = isDebug()
|
||||
? {} // miniflare doesn't support range requests
|
||||
: {
|
||||
range: headers,
|
||||
onlyIf: headers
|
||||
};
|
||||
object = (await this.bucket.get(key, options)) as R2ObjectBody;
|
||||
|
||||
if (!object) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
if (object.range) {
|
||||
const offset = "offset" in object.range ? object.range.offset : 0;
|
||||
const end = "end" in object.range ? object.range.end : object.size - 1;
|
||||
responseHeaders.set("Content-Range", `bytes ${offset}-${end}/${object.size}`);
|
||||
responseHeaders.set("Connection", "keep-alive");
|
||||
responseHeaders.set("Vary", "Accept-Encoding");
|
||||
}
|
||||
} else {
|
||||
object = (await this.bucket.get(key)) as R2ObjectBody;
|
||||
|
||||
if (object === null) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("response headers:before", headersToObject(responseHeaders));
|
||||
this.writeHttpMetadata(responseHeaders, object);
|
||||
responseHeaders.set("etag", object.httpEtag);
|
||||
responseHeaders.set("Content-Length", String(object.size));
|
||||
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
|
||||
//console.log("response headers:after", headersToObject(responseHeaders));
|
||||
|
||||
return new Response(object.body, {
|
||||
status: object.range ? 206 : 200,
|
||||
headers: responseHeaders
|
||||
});
|
||||
}
|
||||
|
||||
private writeHttpMetadata(headers: Headers, object: R2Object | R2ObjectBody): void {
|
||||
let metadata = object.httpMetadata;
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
// guessing is especially required for dev environment (miniflare)
|
||||
metadata = {
|
||||
contentType: guess(object.key)
|
||||
};
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||
headers.set(camelToDash, value);
|
||||
}
|
||||
}
|
||||
|
||||
async getObjectMeta(key: string): Promise<{ type: string; size: number }> {
|
||||
const head = await this.headObject(key);
|
||||
if (!head) {
|
||||
throw new Error("Object not found");
|
||||
}
|
||||
|
||||
return {
|
||||
type: String(head.httpMetadata?.contentType ?? guess(key)),
|
||||
size: head.size
|
||||
};
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
await this.bucket.delete(key);
|
||||
}
|
||||
|
||||
getObjectUrl(key: string): string {
|
||||
throw new Error("Method getObjectUrl not implemented.");
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
return {
|
||||
type: this.getName(),
|
||||
config: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
32
app/src/adapter/cloudflare/bindings.ts
Normal file
32
app/src/adapter/cloudflare/bindings.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type BindingTypeMap = {
|
||||
D1Database: D1Database;
|
||||
KVNamespace: KVNamespace;
|
||||
DurableObjectNamespace: DurableObjectNamespace;
|
||||
R2Bucket: R2Bucket;
|
||||
};
|
||||
|
||||
export type GetBindingType = keyof BindingTypeMap;
|
||||
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
||||
|
||||
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
||||
const bindings: BindingMap<T>[] = [];
|
||||
for (const key in env) {
|
||||
try {
|
||||
if (env[key] && (env[key] as any).constructor.name === type) {
|
||||
bindings.push({
|
||||
key,
|
||||
value: env[key] as BindingTypeMap[T]
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function getBinding<T extends GetBindingType>(env: any, type: T): BindingMap<T> {
|
||||
const bindings = getBindings(env, type);
|
||||
if (bindings.length === 0) {
|
||||
throw new Error(`No ${type} found in bindings`);
|
||||
}
|
||||
return bindings[0] as BindingMap<T>;
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import { D1Connection } from "./connection/D1Connection";
|
||||
import { D1Connection } from "./D1Connection";
|
||||
import { registerMedia } from "./StorageR2Adapter";
|
||||
import { getBinding } from "./bindings";
|
||||
import { getCached } from "./modes/cached";
|
||||
import { getDurable } from "./modes/durable";
|
||||
import { getFresh, getWarm } from "./modes/fresh";
|
||||
@@ -30,6 +32,38 @@ export type Context<Env = any> = {
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
|
||||
let media_registered: boolean = false;
|
||||
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
|
||||
if (!media_registered) {
|
||||
registerMedia(context.env as any);
|
||||
media_registered = true;
|
||||
}
|
||||
|
||||
const appConfig = makeConfig(config, context);
|
||||
const bindings = config.bindings?.(context);
|
||||
if (!appConfig.connection) {
|
||||
let db: D1Database | undefined;
|
||||
if (bindings?.db) {
|
||||
console.log("Using database from bindings");
|
||||
db = bindings.db;
|
||||
} else if (Object.keys(context.env ?? {}).length > 0) {
|
||||
const binding = getBinding(context.env, "D1Database");
|
||||
if (binding) {
|
||||
console.log(`Using database from env "${binding.key}"`);
|
||||
db = binding.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (db) {
|
||||
appConfig.connection = new D1Connection({ binding: db });
|
||||
} else {
|
||||
throw new Error("No database connection given");
|
||||
}
|
||||
}
|
||||
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
||||
return {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
@@ -68,43 +102,15 @@ export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
||||
const context = { request, env, ctx } as Context;
|
||||
const mode = config.mode ?? "warm";
|
||||
|
||||
const appConfig = makeConfig(config, context);
|
||||
const bindings = config.bindings?.(context);
|
||||
if (!appConfig.connection) {
|
||||
let db: D1Database | undefined;
|
||||
if (bindings && "db" in bindings && bindings.db) {
|
||||
console.log("Using database from bindings");
|
||||
db = bindings.db;
|
||||
} else if (env && Object.keys(env).length > 0) {
|
||||
// try to find a database in env
|
||||
for (const key in env) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (env[key].constructor.name === "D1Database") {
|
||||
console.log(`Using database from env "${key}"`);
|
||||
db = env[key] as D1Database;
|
||||
break;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (db) {
|
||||
appConfig.connection = new D1Connection({ binding: db });
|
||||
} else {
|
||||
throw new Error("No database connection given");
|
||||
}
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case "fresh":
|
||||
return await getFresh(appConfig, context);
|
||||
return await getFresh(config, context);
|
||||
case "warm":
|
||||
return await getWarm(appConfig, context);
|
||||
return await getWarm(config, context);
|
||||
case "cache":
|
||||
return await getCached(appConfig, context);
|
||||
return await getCached(config, context);
|
||||
case "durable":
|
||||
return await getDurable(appConfig, context);
|
||||
return await getDurable(config, context);
|
||||
default:
|
||||
throw new Error(`Unknown mode ${mode}`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
|
||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||
export { getCached } from "./modes/cached";
|
||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||
export { D1Connection, type D1ConnectionConfig };
|
||||
export {
|
||||
getBinding,
|
||||
getBindings,
|
||||
type BindingTypeMap,
|
||||
type GetBindingType,
|
||||
type BindingMap
|
||||
} from "./bindings";
|
||||
|
||||
export function d1(config: D1ConnectionConfig) {
|
||||
return new D1Connection(config);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { App } from "bknd";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
||||
|
||||
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
|
||||
const { kv } = config.bindings?.(env)!;
|
||||
@@ -16,7 +16,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
|
||||
|
||||
const app = await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
...makeCfConfig(config, { env, ctx, ...args }),
|
||||
initialConfig,
|
||||
onBuilt: async (app) => {
|
||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { App } from "bknd";
|
||||
import { createRuntimeApp } from "bknd/adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
||||
|
||||
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
...makeCfConfig(config, ctx),
|
||||
adminOptions: config.html ? { html: config.html } : undefined
|
||||
},
|
||||
ctx
|
||||
|
||||
Reference in New Issue
Block a user