From bf521e293150323edf919117e7e5a81b9a144195 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 3 Sep 2025 07:54:40 +0200 Subject: [PATCH] cloudflare: fixing multiple instances competing with configuration state by always serving fresh --- app/src/adapter/adapter-test-suite.ts | 4 +- .../cloudflare-workers.adapter.spec.ts | 14 ++--- .../cloudflare/cloudflare-workers.adapter.ts | 56 ++++++++++--------- app/src/adapter/cloudflare/config.ts | 12 ++-- app/src/adapter/cloudflare/index.ts | 11 ++-- app/src/adapter/cloudflare/modes/cached.ts | 53 ------------------ app/src/adapter/cloudflare/modes/fresh.ts | 29 ---------- app/src/adapter/index.ts | 6 +- app/src/data/api/DataController.ts | 14 +---- app/src/modules/Controller.ts | 2 +- app/src/modules/server/SystemController.ts | 1 + .../integration/(runtimes)/cloudflare.mdx | 46 +-------------- 12 files changed, 61 insertions(+), 187 deletions(-) delete mode 100644 app/src/adapter/cloudflare/modes/cached.ts delete mode 100644 app/src/adapter/cloudflare/modes/fresh.ts diff --git a/app/src/adapter/adapter-test-suite.ts b/app/src/adapter/adapter-test-suite.ts index 0ddb2b7..dba432b 100644 --- a/app/src/adapter/adapter-test-suite.ts +++ b/app/src/adapter/adapter-test-suite.ts @@ -53,7 +53,7 @@ export function adapterTestSuite< url: overrides.dbUrl ?? ":memory:", origin: "localhost", } as any, - { id }, + { force: false, id }, ); expect(app).toBeDefined(); expect(app.toJSON().server.cors.origin).toEqual("localhost"); @@ -69,7 +69,7 @@ export function adapterTestSuite< }; test("responds with the same app id", async () => { - const fetcher = makeHandler(undefined, undefined, { id }); + const fetcher = makeHandler(undefined, undefined, { force: false, id }); const { res, data } = await getConfig(fetcher); expect(res.ok).toBe(true); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 401722c..64ba65b 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -1,10 +1,9 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { makeApp } from "./modes/fresh"; -import { makeConfig, type CfMakeConfigArgs } from "./config"; +import { makeConfig, type CloudflareContext } from "./config"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { adapterTestSuite } from "adapter/adapter-test-suite"; import { bunTestRunner } from "adapter/bun/test"; -import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter"; +import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter"; beforeAll(disableConsoleLog); afterAll(enableConsoleLog); @@ -41,18 +40,19 @@ describe("cf adapter", () => { expect(dynamicConfig.connection).toBeDefined(); }); - adapterTestSuite>(bunTestRunner, { + adapterTestSuite>(bunTestRunner, { makeApp: async (c, a, o) => { - return await makeApp(c, { env: a } as any, o); + return await createApp(c, { env: a } as any, o); }, makeHandler: (c, a, o) => { + console.log("args", a); return async (request: any) => { - const app = await makeApp( + const app = await createApp( // needs a fallback, otherwise tries to launch D1 c ?? { connection: { url: DB_URL }, }, - a!, + a as any, o, ); return app.fetch(request); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 4cd03ca..091dc30 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -3,10 +3,10 @@ import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import { getFresh } from "./modes/fresh"; -import { getCached } from "./modes/cached"; -import type { App, MaybePromise } from "bknd"; +import type { MaybePromise } from "bknd"; import { $console } from "bknd/utils"; +import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; +import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; declare global { namespace Cloudflare { @@ -16,7 +16,6 @@ declare global { export type CloudflareEnv = Cloudflare.Env; export type CloudflareBkndConfig = RuntimeBkndConfig & { - mode?: "warm" | "fresh" | "cache"; bindings?: (args: Env) => MaybePromise<{ kv?: KVNamespace; db?: D1Database; @@ -34,11 +33,31 @@ export type CloudflareBkndConfig = RuntimeBkndConfig & registerMedia?: boolean | ((env: Env) => void); }; -export type Context = { - request: Request; - env: Env; - ctx: ExecutionContext; -}; +export async function createApp( + config: CloudflareBkndConfig, + ctx: Partial> = {}, + opts: RuntimeOptions = { + // by default, require the app to be rebuilt every time + force: true, + }, +) { + const appConfig = await makeConfig( + { + ...config, + onBuilt: async (app) => { + if (ctx.ctx) { + registerAsyncsExecutionContext(app, ctx?.ctx); + } + await config.onBuilt?.(app); + }, + }, + ctx, + ); + return await createRuntimeApp(appConfig, ctx?.env, opts); +} + +// compatiblity +export const getFresh = createApp; export function serve( config: CloudflareBkndConfig = {}, @@ -77,23 +96,8 @@ export function serve( } } - const context = { request, env, ctx } as Context; - const mode = config.mode ?? "warm"; - - let app: App; - switch (mode) { - case "fresh": - app = await getFresh(config, context, { force: true }); - break; - case "warm": - app = await getFresh(config, context); - break; - case "cache": - app = await getCached(config, context); - break; - default: - throw new Error(`Unknown mode ${mode}`); - } + const context = { request, env, ctx } as CloudflareContext; + const app = await createApp(config, context); return app.fetch(request, env, ctx); }, diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 0a70249..86a7722 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -8,7 +8,7 @@ import { getBinding } from "./bindings"; import { d1Sqlite } from "./connection/D1Connection"; import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; -import type { Context, ExecutionContext } from "hono"; +import type { Context as HonoContext, ExecutionContext } from "hono"; import { $console } from "bknd/utils"; import { setCookie } from "hono/cookie"; @@ -22,10 +22,10 @@ export const constants = { }, }; -export type CfMakeConfigArgs = { +export type CloudflareContext = { env: Env; - ctx?: ExecutionContext; - request?: Request; + ctx: ExecutionContext; + request: Request; }; function getCookieValue(cookies: string | null, name: string) { @@ -67,7 +67,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig) { return undefined; }, - set: (c: Context, d1?: D1DatabaseSession) => { + set: (c: HonoContext, d1?: D1DatabaseSession) => { if (!d1 || !config.d1?.session) return; const session = d1.getBookmark(); @@ -91,7 +91,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig) { let media_registered: boolean = false; export async function makeConfig( config: CloudflareBkndConfig, - args?: CfMakeConfigArgs, + args?: Partial>, ) { if (!media_registered && config.registerMedia !== false) { if (typeof config.registerMedia === "function") { diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index a7d249a..24f0459 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,8 +1,11 @@ import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection"; -export * from "./cloudflare-workers.adapter"; -export { makeApp, getFresh } from "./modes/fresh"; -export { getCached } from "./modes/cached"; +export { + getFresh, + createApp, + type CloudflareEnv, + type CloudflareBkndConfig, +} from "./cloudflare-workers.adapter"; export { d1Sqlite, type D1ConnectionConfig }; export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection"; export { @@ -12,7 +15,7 @@ export { type GetBindingType, type BindingMap, } from "./bindings"; -export { constants } from "./config"; +export { constants, type CloudflareContext } from "./config"; export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter"; export { registries } from "bknd"; export { devFsVitePlugin, devFsWrite } from "./vite"; diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts deleted file mode 100644 index fdbed21..0000000 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { App } from "bknd"; -import { createRuntimeApp } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { makeConfig, registerAsyncsExecutionContext, constants } from "../config"; - -export async function getCached( - config: CloudflareBkndConfig, - args: Context, -) { - const { env, ctx } = args; - const { kv } = await config.bindings?.(env)!; - if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); - const key = config.key ?? "app"; - - const cachedConfig = await kv.get(key); - const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined; - - async function saveConfig(__config: any) { - ctx.waitUntil(kv!.put(key, JSON.stringify(__config))); - } - - const app = await createRuntimeApp( - { - ...makeConfig(config, args), - initialConfig, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, ctx); - app.module.server.client.get(constants.cache_endpoint, async (c) => { - await kv.delete(key); - return c.json({ message: "Cache cleared" }); - }); - await config.onBuilt?.(app); - }, - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppConfigUpdatedEvent, - async ({ params: { app } }) => { - saveConfig(app.toJSON(true)); - }, - "sync", - ); - await config.beforeBuild?.(app); - }, - }, - args, - ); - - if (!cachedConfig) { - saveConfig(app.toJSON(true)); - } - - return app; -} diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts deleted file mode 100644 index 5a3ad22..0000000 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config"; - -export async function makeApp( - config: CloudflareBkndConfig, - args?: CfMakeConfigArgs, - opts?: RuntimeOptions, -) { - return await createRuntimeApp(await makeConfig(config, args), args?.env, opts); -} - -export async function getFresh( - config: CloudflareBkndConfig, - ctx: Context, - opts: RuntimeOptions = {}, -) { - return await makeApp( - { - ...config, - onBuilt: async (app) => { - registerAsyncsExecutionContext(app, ctx.ctx); - await config.onBuilt?.(app); - }, - }, - ctx, - opts, - ); -} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 1990b9f..91ffcf7 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -83,8 +83,12 @@ export async function createAdapterApp(name: string, h: HH): any { - const func = h; - // @ts-ignore - func.description = name; - return func; - } - // info hono.get( "/", @@ -52,10 +43,7 @@ export class DataController extends Controller { summary: "Retrieve data configuration", tags: ["data"], }), - handler("data info", (c) => { - // sample implementation - return c.json(this.em.toJSON()); - }), + (c) => c.json(this.em.toJSON()), ); // sync endpoint diff --git a/app/src/modules/Controller.ts b/app/src/modules/Controller.ts index db9f3d8..fdda095 100644 --- a/app/src/modules/Controller.ts +++ b/app/src/modules/Controller.ts @@ -51,7 +51,7 @@ export class Controller { protected getEntitiesEnum(em: EntityManager): s.StringSchema { const entities = em.entities.map((e) => e.name); - return entities.length > 0 ? s.string({ enum: entities }) : s.string(); + return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string(); } registerMcp(): void {} diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 25b20a0..7beda79 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -376,6 +376,7 @@ export class SystemController extends Controller { }), (c) => c.json({ + id: this.app._id, version: { config: c.get("app")?.version(), bknd: getVersion(), diff --git a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx index daeadea..9ad826b 100644 --- a/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx +++ b/docs/content/docs/(documentation)/integration/(runtimes)/cloudflare.mdx @@ -42,8 +42,7 @@ bun add bknd ## Serve the API -If you don't choose anything specific, the following code will use the `warm` mode and uses the first D1 binding it finds. See the -chapter [Using a different mode](#using-a-different-mode) for available modes. +If you don't choose anything specific, it uses the first D1 binding it finds. ```ts title="src/index.ts" import { serve, d1 } from "bknd/adapter/cloudflare"; @@ -130,46 +129,6 @@ export default serve({ The property `app.server` is a [Hono](https://hono.dev/) instance, you can literally anything you can do with Hono. -## Using a different mode - -With the Cloudflare Workers adapter, you're being offered to 4 modes to choose from (default: -`warm`): - -| Mode | Description | Use Case | -| :-------- | :----------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- | -| `fresh` | On every request, the configuration gets refetched, app built and then served. | Ideal if you don't want to deal with eviction, KV or Durable Objects. | -| `warm` | It tries to keep the built app in memory for as long as possible, and rebuilds if evicted. | Better response times, should be the default choice. | -| `cache` | The configuration is fetched from KV to reduce the initial roundtrip to the database. | Generally faster response times with irregular access patterns. | - -### Modes: `fresh` and `warm` - -To use either `fresh` or `warm`, all you have to do is adding the desired mode to `cloudflare. -mode`, like so: - -```ts -import { serve } from "bknd/adapter/cloudflare"; - -export default serve({ - // ... - mode: "fresh", // mode: "fresh" | "warm" | "cache" | "durable" -}); -``` - -### Mode: `cache` - -For the cache mode to work, you also need to specify the KV to be used. For this, use the -`bindings` property: - -```ts -import { serve } from "bknd/adapter/cloudflare"; - -export default serve({ - // ... - mode: "cache", - bindings: ({ env }) => ({ kv: env.KV }), -}); -``` - ## D1 Sessions (experimental) D1 now supports to enable [global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). This allows to reduce latency by reading from the closest region. In order for this to work, D1 has to be started from a bookmark. You can enable this behavior on bknd by setting the `d1.session` property: @@ -178,9 +137,6 @@ D1 now supports to enable [global read replication](https://developers.cloudflar import { serve } from "bknd/adapter/cloudflare"; export default serve({ - // currently recommended to use "fresh" mode - // otherwise consecutive requests will use the same bookmark - mode: "fresh", // ... d1: { // enables D1 sessions