diff --git a/app/src/adapter/cloudflare/connection/D1Connection.ts b/app/src/adapter/cloudflare/D1Connection.ts similarity index 91% rename from app/src/adapter/cloudflare/connection/D1Connection.ts rename to app/src/adapter/cloudflare/D1Connection.ts index d47458d..11e1a44 100644 --- a/app/src/adapter/cloudflare/connection/D1Connection.ts +++ b/app/src/adapter/cloudflare/D1Connection.ts @@ -1,9 +1,7 @@ /// -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"; diff --git a/app/src/media/storage/adapters/StorageR2Adapter.ts b/app/src/adapter/cloudflare/StorageR2Adapter.ts similarity index 72% rename from app/src/media/storage/adapters/StorageR2Adapter.ts rename to app/src/adapter/cloudflare/StorageR2Adapter.ts index 911fe37..aedeb10 100644 --- a/app/src/media/storage/adapters/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/StorageR2Adapter.ts @@ -1,6 +1,47 @@ -import { isDebug } from "core"; -import type { FileBody, StorageAdapter } from "../Storage"; -import { guessMimeType } from "../mime-types"; +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) { + 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 @@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter { } getSchema() { - return undefined; + return makeSchema(); } async putObject(key: string, body: FileBody) { @@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter { async getObject(key: string, headers: Headers): Promise { let object: R2ObjectBody | null; const responseHeaders = new Headers({ - "Accept-Ranges": "bytes" + "Accept-Ranges": "bytes", + "Content-Type": guess(key) }); //console.log("getObject:headers", headersToObject(headers)); @@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter { if (!metadata || Object.keys(metadata).length === 0) { // guessing is especially required for dev environment (miniflare) metadata = { - contentType: guessMimeType(object.key) + contentType: guess(object.key) }; } - //console.log("writeHttpMetadata", object.httpMetadata, metadata); for (const [key, value] of Object.entries(metadata)) { const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase(); @@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter { } return { - type: String(head.httpMetadata?.contentType ?? "application/octet-stream"), + type: String(head.httpMetadata?.contentType ?? guess(key)), size: head.size }; } diff --git a/app/src/adapter/cloudflare/bindings.ts b/app/src/adapter/cloudflare/bindings.ts new file mode 100644 index 0000000..491d0a8 --- /dev/null +++ b/app/src/adapter/cloudflare/bindings.ts @@ -0,0 +1,32 @@ +export type BindingTypeMap = { + D1Database: D1Database; + KVNamespace: KVNamespace; + DurableObjectNamespace: DurableObjectNamespace; + R2Bucket: R2Bucket; +}; + +export type GetBindingType = keyof BindingTypeMap; +export type BindingMap = { key: string; value: BindingTypeMap[T] }; + +export function getBindings(env: any, type: T): BindingMap[] { + const bindings: BindingMap[] = []; + 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(env: any, type: T): BindingMap { + const bindings = getBindings(env, type); + if (bindings.length === 0) { + throw new Error(`No ${type} found in bindings`); + } + return bindings[0] as BindingMap; +} diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index d12d02b..4486609 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -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 = { 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(config: CloudflareBkndConfig = {}) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { @@ -68,43 +102,15 @@ export function serve(config: CloudflareBkndConfig = {}) { 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}`); } diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index a173c2e..a10dc53 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -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); diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index a367e5d..48f6926 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -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) => { diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index ef40987..7a2af67 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -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 diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 564b008..dda2cf6 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -40,7 +40,8 @@ export class AppMedia extends Module { let adapter: StorageAdapter; try { const { type, config } = this.config.adapter; - adapter = new (registry.get(type as any).cls)(config as any); + const cls = registry.get(type as any).cls; + adapter = new cls(config as any); this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this.setBuilt(); @@ -53,8 +54,6 @@ export class AppMedia extends Module { index(media).on(["path"], true).on(["reference"]); }) ); - - this.setBuilt(); } catch (e) { console.error(e); throw new Error( diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index f196c79..229b108 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -16,8 +16,8 @@ export function buildMediaSchema() { config: adapter.schema }, { - title: adapter.schema.title ?? name, - description: adapter.schema.description, + title: adapter.schema?.title ?? name, + description: adapter.schema?.description, additionalProperties: false } ); diff --git a/app/src/ui/routes/media/media.settings.tsx b/app/src/ui/routes/media/media.settings.tsx index 68b9b04..f6eb4e8 100644 --- a/app/src/ui/routes/media/media.settings.tsx +++ b/app/src/ui/routes/media/media.settings.tsx @@ -1,4 +1,4 @@ -import { IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react"; +import { IconBrandAws, IconBrandCloudflare, IconCloud, IconServer } from "@tabler/icons-react"; import { isDebug } from "core"; import { autoFormatString } from "core/utils"; import { twMerge } from "tailwind-merge"; @@ -113,10 +113,15 @@ const RootFormError = () => { ); }; -const Icons = [IconBrandAws, IconCloud, IconServer]; +const Icons = { + s3: IconBrandAws, + cloudinary: IconCloud, + local: IconServer, + r2: IconBrandCloudflare +}; -const AdapterIcon = ({ index }: { index: number }) => { - const Icon = Icons[index]; +const AdapterIcon = ({ type }: { type: string }) => { + const Icon = Icons[type]; if (!Icon) return null; return ; }; @@ -142,7 +147,7 @@ function Adapters() { )} >
- +
{autoFormatString(schema.title)} diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index 5d28a61..c2114d3 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -2,4 +2,8 @@ import { serve } from "bknd/adapter/cloudflare"; -export default serve(); +export default serve({ + onBuilt: async (app) => { + app.modules.server.get("/custom", (c) => c.json({ hello: "world" })); + } +}); diff --git a/examples/cloudflare-worker/wrangler.toml b/examples/cloudflare-worker/wrangler.toml index 7f049ef..993970a 100644 --- a/examples/cloudflare-worker/wrangler.toml +++ b/examples/cloudflare-worker/wrangler.toml @@ -13,4 +13,8 @@ enabled = true [[d1_databases]] binding = "DB" database_name = "bknd-cf-example" -database_id = "7ad67953-2bbf-47fc-8696-f4517dbfe674" \ No newline at end of file +database_id = "7ad67953-2bbf-47fc-8696-f4517dbfe674" + +[[r2_buckets]] +binding = "BUCKET" +bucket_name = "bknd-cf-example" \ No newline at end of file