Merge pull request #27 from bknd-io/feat/optimize-cf-adapter

improved cloudflare worker adapter
This commit is contained in:
dswbx
2024-12-23 11:44:52 +01:00
committed by GitHub
8 changed files with 413 additions and 278 deletions

View File

@@ -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<CfBkndModeCache>;
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);
}
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -1,19 +1,14 @@
import type { IncomingMessage } from "node:http";
import type { App, CreateAppConfig } from "bknd";
export type CfBkndModeCache<Env = any> = (env: Env) => {
cache: KVNamespace;
key: string;
};
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
durableObject: DurableObjectNamespace;
key: string;
keepAliveSeconds?: number;
};
export type CloudflareBkndConfig<Env = any> = {
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<Env = any> = {
onBuilt?: (app: App) => Promise<void>;
};
export type BkndConfigJson = {
app: CreateAppConfig;
setAdminHtml?: boolean;
server?: {
port?: number;
};
};
export function nodeRequestToRequest(req: IncomingMessage): Request {
let protocol = "http";
try {

View File

@@ -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]}]` : "";