From 32459a1562c7aef7623c078eea40312a30527338 Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 11 Dec 2024 17:09:28 +0100 Subject: [PATCH] improved cloudflare worker adapter + added docs about different modes --- .../cloudflare/cloudflare-workers.adapter.ts | 232 ++---------------- app/src/adapter/cloudflare/index.ts | 3 + app/src/adapter/cloudflare/modes/cached.ts | 55 +++++ app/src/adapter/cloudflare/modes/durable.ts | 136 ++++++++++ app/src/adapter/cloudflare/modes/fresh.ts | 39 +++ app/src/adapter/index.ts | 27 +- app/src/core/utils/DebugLogger.ts | 7 +- docs/integration/cloudflare.mdx | 192 +++++++++++---- 8 files changed, 413 insertions(+), 278 deletions(-) create mode 100644 app/src/adapter/cloudflare/modes/cached.ts create mode 100644 app/src/adapter/cloudflare/modes/durable.ts create mode 100644 app/src/adapter/cloudflare/modes/fresh.ts diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 0d2126a..1603967 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,10 +1,11 @@ -import { DurableObject } from "cloudflare:workers"; -import { App, type CreateAppConfig } from "bknd"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { BkndConfig, CfBkndModeCache } from "../index"; +import type { BkndConfig } from "../index"; +import { getCached } from "./modes/cached"; +import { getDurable } from "./modes/durable"; +import { getFresh, getWarm } from "./modes/fresh"; -type Context = { +export type Context = { request: Request; env: any; ctx: ExecutionContext; @@ -30,9 +31,7 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) { onNotFound: (path) => console.log("not found", path) })(c as any, next); if (res instanceof Response) { - const ttl = pathname.startsWith("assets/") - ? 60 * 60 * 24 * 365 // 1 year - : 60 * 5; // 5 minutes + const ttl = 60 * 60 * 24 * 365; res.headers.set("Cache-Control", `public, max-age=${ttl}`); return res; } @@ -48,214 +47,21 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) { ..._config, setAdminHtml: _config.setAdminHtml ?? !!manifest }; - const context = { request, env, ctx, manifest, html }; - const mode = config.cloudflare?.mode?.(env); + const context = { request, env, ctx, manifest, html } as Context; + const mode = config.cloudflare?.mode ?? "warm"; - if (!mode) { - console.log("serving fresh..."); - const app = await getFresh(config, context); - return app.fetch(request, env); - } else if ("cache" in mode) { - console.log("serving cached..."); - const app = await getCached(config as any, context); - return app.fetch(request, env); - } else if ("durableObject" in mode) { - console.log("serving durable..."); - - if (config.onBuilt) { - console.log("onBuilt() is not supported with DurableObject mode"); - } - - const start = performance.now(); - - const durable = mode.durableObject; - const id = durable.idFromName(mode.key); - const stub = durable.get(id) as unknown as DurableBkndApp; - - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - - const res = await stub.fire(request, { - config: create_config, - html, - keepAliveSeconds: mode.keepAliveSeconds, - setAdminHtml: config.setAdminHtml - }); - - const headers = new Headers(res.headers); - headers.set("X-TTDO", String(performance.now() - start)); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers - }); + switch (mode) { + case "fresh": + return await getFresh(config, context); + case "warm": + return await getWarm(config, context); + case "cache": + return await getCached(config, context); + case "durable": + return await getDurable(config, context); + default: + throw new Error(`Unknown mode ${mode}`); } } }; } - -async function getFresh(config: BkndConfig, { env, html }: Context) { - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - const app = App.create(create_config); - - if (config.onBuilt) { - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async ({ params: { app } }) => { - config.onBuilt!(app); - }, - "sync" - ); - } - await app.build(); - - if (config.setAdminHtml) { - app.registerAdminController({ html }); - } - - return app; -} - -async function getCached( - config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } }, - { env, html, ctx }: Context -) { - const { cache, key } = config.cloudflare.mode(env) as ReturnType; - const create_config = typeof config.app === "function" ? config.app(env) : config.app; - - const cachedConfig = await cache.get(key); - const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined; - - const app = App.create({ ...create_config, initialConfig }); - - async function saveConfig(__config: any) { - ctx.waitUntil(cache.put(key, JSON.stringify(__config))); - } - - if (config.onBuilt) { - app.emgr.onEvent( - App.Events.AppBuiltEvent, - async ({ params: { app } }) => { - app.module.server.client.get("/__bknd/cache", async (c) => { - await cache.delete(key); - return c.json({ message: "Cache cleared" }); - }); - app.registerAdminController({ html }); - - config.onBuilt!(app); - }, - "sync" - ); - } - - app.emgr.onEvent( - App.Events.AppConfigUpdatedEvent, - async ({ params: { app } }) => { - saveConfig(app.toJSON(true)); - }, - "sync" - ); - - await app.build(); - - if (config.setAdminHtml) { - app.registerAdminController({ html }); - } - - if (!cachedConfig) { - saveConfig(app.toJSON(true)); - } - - return app; -} - -export class DurableBkndApp extends DurableObject { - protected id = Math.random().toString(36).slice(2); - protected app?: App; - protected interval?: any; - - async fire( - request: Request, - options: { - config: CreateAppConfig; - html?: string; - keepAliveSeconds?: number; - setAdminHtml?: boolean; - } - ) { - let buildtime = 0; - if (!this.app) { - const start = performance.now(); - const config = options.config; - - // change protocol to websocket if libsql - if ( - config?.connection && - "type" in config.connection && - config.connection.type === "libsql" - ) { - config.connection.config.protocol = "wss"; - } - - this.app = App.create(config); - this.app.emgr.onEvent( - App.Events.AppBuiltEvent, - async ({ params: { app } }) => { - app.modules.server.get("/__do", async (c) => { - // @ts-ignore - const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; - return c.json({ - id: this.id, - keepAlive: options?.keepAliveSeconds, - colo: context.colo - }); - }); - }, - "sync" - ); - - await this.app.build(); - - buildtime = performance.now() - start; - } - - if (options?.keepAliveSeconds) { - this.keepAlive(options.keepAliveSeconds); - } - - console.log("id", this.id); - const res = await this.app!.fetch(request); - const headers = new Headers(res.headers); - headers.set("X-BuildTime", buildtime.toString()); - headers.set("X-DO-ID", this.id); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers - }); - } - - protected keepAlive(seconds: number) { - console.log("keep alive for", seconds); - if (this.interval) { - console.log("clearing, there is a new"); - clearInterval(this.interval); - } - - let i = 0; - this.interval = setInterval(() => { - i += 1; - //console.log("keep-alive", i); - if (i === seconds) { - console.log("cleared"); - clearInterval(this.interval); - - // ping every 30 seconds - } else if (i % 30 === 0) { - console.log("ping"); - this.app?.modules.ctx().connection.ping(); - } - }, 1000); - } -} diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index f2d3cdd..c2dd1c5 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1 +1,4 @@ export * from "./cloudflare-workers.adapter"; +export { makeApp, getFresh, getWarm } from "./modes/fresh"; +export { getCached } from "./modes/cached"; +export { DurableBkndApp, getDurable } from "./modes/durable"; diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts new file mode 100644 index 0000000..adcd5a3 --- /dev/null +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -0,0 +1,55 @@ +import type { BkndConfig } from "adapter"; +import { App } from "bknd"; +import type { Context } from "../index"; + +export async function getCached(config: BkndConfig, { env, html, ctx }: Context) { + const { kv } = config.cloudflare?.bindings?.(env)!; + if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); + const key = config.cloudflare?.key ?? "app"; + + const create_config = typeof config.app === "function" ? config.app(env) : config.app; + const cachedConfig = await kv.get(key); + const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined; + + const app = App.create({ ...create_config, initialConfig }); + + async function saveConfig(__config: any) { + ctx.waitUntil(kv!.put(key, JSON.stringify(__config))); + } + + if (config.onBuilt) { + app.emgr.onEvent( + App.Events.AppBuiltEvent, + async ({ params: { app } }) => { + app.module.server.client.get("/__bknd/cache", async (c) => { + await kv.delete(key); + return c.json({ message: "Cache cleared" }); + }); + app.registerAdminController({ html }); + + config.onBuilt!(app); + }, + "sync" + ); + } + + app.emgr.onEvent( + App.Events.AppConfigUpdatedEvent, + async ({ params: { app } }) => { + saveConfig(app.toJSON(true)); + }, + "sync" + ); + + await app.build(); + + if (config.setAdminHtml) { + app.registerAdminController({ html }); + } + + if (!cachedConfig) { + saveConfig(app.toJSON(true)); + } + + return app; +} diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts new file mode 100644 index 0000000..60f7a0e --- /dev/null +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -0,0 +1,136 @@ +import { DurableObject } from "cloudflare:workers"; +import type { BkndConfig } from "adapter"; +import type { Context } from "adapter/cloudflare"; +import { App, type CreateAppConfig } from "bknd"; + +export async function getDurable(config: BkndConfig, ctx: Context) { + const { dobj } = config.cloudflare?.bindings?.(ctx.env)!; + if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); + const key = config.cloudflare?.key ?? "app"; + + if (config.onBuilt) { + console.log("onBuilt() is not supported with DurableObject mode"); + } + + const start = performance.now(); + + const id = dobj.idFromName(key); + const stub = dobj.get(id) as unknown as DurableBkndApp; + + const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app; + + const res = await stub.fire(ctx.request, { + config: create_config, + html: ctx.html, + keepAliveSeconds: config.cloudflare?.keepAliveSeconds, + setAdminHtml: config.setAdminHtml + }); + + const headers = new Headers(res.headers); + headers.set("X-TTDO", String(performance.now() - start)); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers + }); +} + +export class DurableBkndApp extends DurableObject { + protected id = Math.random().toString(36).slice(2); + protected app?: App; + protected interval?: any; + + async fire( + request: Request, + options: { + config: CreateAppConfig; + html?: string; + keepAliveSeconds?: number; + setAdminHtml?: boolean; + } + ) { + let buildtime = 0; + if (!this.app) { + const start = performance.now(); + const config = options.config; + + // change protocol to websocket if libsql + if ( + config?.connection && + "type" in config.connection && + config.connection.type === "libsql" + ) { + config.connection.config.protocol = "wss"; + } + + this.app = App.create(config); + this.app.emgr.onEvent( + App.Events.AppBuiltEvent, + async ({ params: { app } }) => { + app.modules.server.get("/__do", async (c) => { + // @ts-ignore + const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; + return c.json({ + id: this.id, + keepAliveSeconds: options?.keepAliveSeconds ?? 0, + colo: context.colo + }); + }); + + await this.onBuilt(app); + }, + "sync" + ); + + await this.app.build(); + + if (options.setAdminHtml) { + this.app.registerAdminController({ html: options.html }); + } + + buildtime = performance.now() - start; + } + + if (options?.keepAliveSeconds) { + this.keepAlive(options.keepAliveSeconds); + } + + console.log("id", this.id); + const res = await this.app!.fetch(request); + const headers = new Headers(res.headers); + headers.set("X-BuildTime", buildtime.toString()); + headers.set("X-DO-ID", this.id); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers + }); + } + + async onBuilt(app: App) {} + + protected keepAlive(seconds: number) { + console.log("keep alive for", seconds); + if (this.interval) { + console.log("clearing, there is a new"); + clearInterval(this.interval); + } + + let i = 0; + this.interval = setInterval(() => { + i += 1; + //console.log("keep-alive", i); + if (i === seconds) { + console.log("cleared"); + clearInterval(this.interval); + + // ping every 30 seconds + } else if (i % 30 === 0) { + console.log("ping"); + this.app?.modules.ctx().connection.ping(); + } + }, 1000); + } +} diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts new file mode 100644 index 0000000..639a20d --- /dev/null +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -0,0 +1,39 @@ +import type { BkndConfig } from "adapter"; +import { App } from "bknd"; +import type { Context } from "../index"; + +export async function makeApp(config: BkndConfig, { env, html }: Context) { + const create_config = typeof config.app === "function" ? config.app(env) : config.app; + const app = App.create(create_config); + + if (config.onBuilt) { + app.emgr.onEvent( + App.Events.AppBuiltEvent, + async ({ params: { app } }) => { + config.onBuilt!(app); + }, + "sync" + ); + } + await app.build(); + + if (config.setAdminHtml) { + app.registerAdminController({ html }); + } + + return app; +} + +export async function getFresh(config: BkndConfig, ctx: Context) { + const app = await makeApp(config, ctx); + return app.fetch(ctx.request); +} + +let warm_app: App; +export async function getWarm(config: BkndConfig, ctx: Context) { + if (!warm_app) { + warm_app = await makeApp(config, ctx); + } + + return warm_app.fetch(ctx.request); +} diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index b4b3682..f3d1cb5 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -1,19 +1,14 @@ import type { IncomingMessage } from "node:http"; import type { App, CreateAppConfig } from "bknd"; -export type CfBkndModeCache = (env: Env) => { - cache: KVNamespace; - key: string; -}; - -export type CfBkndModeDurableObject = (env: Env) => { - durableObject: DurableObjectNamespace; - key: string; - keepAliveSeconds?: number; -}; - export type CloudflareBkndConfig = { - mode?: CfBkndModeCache | CfBkndModeDurableObject; + mode?: "warm" | "fresh" | "cache" | "durable"; + bindings?: (env: Env) => { + kv?: KVNamespace; + dobj?: DurableObjectNamespace; + }; + key?: string; + keepAliveSeconds?: number; forceHttps?: boolean; }; @@ -29,14 +24,6 @@ export type BkndConfig = { onBuilt?: (app: App) => Promise; }; -export type BkndConfigJson = { - app: CreateAppConfig; - setAdminHtml?: boolean; - server?: { - port?: number; - }; -}; - export function nodeRequestToRequest(req: IncomingMessage): Request { let protocol = "http"; try { diff --git a/app/src/core/utils/DebugLogger.ts b/app/src/core/utils/DebugLogger.ts index aada58d..e58b45d 100644 --- a/app/src/core/utils/DebugLogger.ts +++ b/app/src/core/utils/DebugLogger.ts @@ -20,11 +20,16 @@ export class DebugLogger { return this; } + reset() { + this.last = 0; + return this; + } + log(...args: any[]) { if (!this._enabled) return this; const now = performance.now(); - const time = Number.parseInt(String(now - this.last)); + const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last)); const indents = " ".repeat(this._context.length); const context = this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : ""; diff --git a/docs/integration/cloudflare.mdx b/docs/integration/cloudflare.mdx index 85e5912..94ff3ce 100644 --- a/docs/integration/cloudflare.mdx +++ b/docs/integration/cloudflare.mdx @@ -13,22 +13,23 @@ and then install bknd as a dependency: ## Serve the API +If you don't choose anything specific, the following code will use the `warm` mode. See the +chapter [Using a different mode](#using-a-different-mode) for available modes. + ``` ts import { serve } from "bknd/adapter/cloudflare"; -export default serve( - { - app: (env: Env) => ({ - connection: { - type: "libsql", - config: { - url: env.DB_URL, - authToken: env.DB_TOKEN - } +export default serve({ + app: (env: Env) => ({ + connection: { + type: "libsql", + config: { + url: env.DB_URL, + authToken: env.DB_TOKEN } - }) - } -); + } + }) +}); ``` For more information about the connection object, refer to the [Setup](/setup) guide. @@ -49,49 +50,152 @@ bucket = "node_modules/bknd/dist/static" ``` And then modify the worker entry as follows: -``` ts {2, 15, 17} +``` ts {2, 14, 15} import { serve } from "bknd/adapter/cloudflare"; import manifest from "__STATIC_CONTENT_MANIFEST"; -export default serve( - { - app: (env: Env) => ({ - connection: { - type: "libsql", - config: { - url: env.DB_URL, - authToken: env.DB_TOKEN - } +export default serve({ + app: (env: Env) => ({ + connection: { + type: "libsql", + config: { + url: env.DB_URL, + authToken: env.DB_TOKEN } - }), - setAdminHtml: true - }, - manifest -); + } + }), + setAdminHtml: true +}, manifest); ``` ## Adding custom routes You can also add custom routes by defining them after the app has been built, like so: -```ts {15-17} +```ts {14-16} import { serve } from "bknd/adapter/cloudflare"; import manifest from "__STATIC_CONTENT_MANIFEST"; -export default serve( - { - app: (env: Env) => ({ - connection: { - type: "libsql", - config: { - url: env.DB_URL, - authToken: env.DB_TOKEN - } +export default serve({ + app: (env: Env) => ({ + connection: { + type: "libsql", + config: { + url: env.DB_URL, + authToken: env.DB_TOKEN } - }), - onBuilt: async (app) => { - app.modules.server.get("/hello", (c) => c.json({ hello: "world" })); - }, - setAdminHtml: true + } + }), + onBuilt: async (app) => { + app.modules.server.get("/hello", (c) => c.json({ hello: "world" })); }, - manifest -); + setAdminHtml: true +}, manifest); +``` + +## 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. | +| `durable` | The bknd app is ran inside a Durable Object and can be configured to stay alive. | Slowest boot time, but fastest responses. Can be kept alive for as long as you want, giving similar response times as server instances. | + +### 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({ + app: (env: Env) => ({ /* ... */ }), + cloudflare: { + mode: "fresh" + // mode: "warm" + } +}); +``` + +### 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({ + app: (env: Env) => ({ /* ... */ }), + cloudflare: { + mode: "cache", + bindings: (env: Env) => ({ kv: env.KV }) + } +}); +``` + +### Mode: `durable` (advanced) +To use the `durable` mode, you have to specify the Durable Object to extract from your +environment, and additionally export the `DurableBkndApp` class: +```ts +import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; + +export { DurableBkndApp }; +export default serve({ + app: (env: Env) => ({ /* ... */ }), + cloudflare: { + mode: "durable", + bindings: (env: Env) => ({ dobj: env.DOBJ }), + keepAliveSeconds: 60 // optional + } +}); +``` + +Next, you need to define the Durable Object in your `wrangler.toml` file (refer to the [Durable +Objects](https://developers.cloudflare.com/durable-objects/) documentation): +```toml +[[durable_objects.bindings]] +name = "DOBJ" +class_name = "DurableBkndApp" + +[[migrations]] +tag = "v1" +new_classes = ["DurableBkndApp"] +``` + +Since the communication between the Worker and Durable Object is serialized, the `onBuilt` +property won't work. To use it (e.g. to specify special routes), you need to extend from the +`DurableBkndApp`: +```ts +import type { App } from "bknd"; +import { serve, DurableBkndApp } from "bknd/adapter/cloudflare"; + +export default serve({ + app: (env: Env) => ({ /* ... */ }), + cloudflare: { + mode: "durable", + bindings: (env: Env) => ({ dobj: env.DOBJ }), + keepAliveSeconds: 60 // optional + } +}); + +export class CustomDurableBkndApp extends DurableBkndApp { + async onBuilt(app: App) { + app.modules.server.get("/custom/endpoint", (c) => c.text("Custom")); + } +} +``` +In case you've already deployed your Worker, the deploy command may complain about a new class +being used. To fix this issue, you need to add a "rename migration": +```toml +[[durable_objects.bindings]] +name = "DOBJ" +class_name = "CustomDurableBkndApp" + +[[migrations]] +tag = "v1" +new_classes = ["DurableBkndApp"] + +[[migrations]] +tag = "v2" +renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}] +deleted_classes = ["DurableBkndApp"] ``` \ No newline at end of file