Merge remote-tracking branch 'origin/release/0.14' into feat/uuid

# Conflicts:
#	app/tsconfig.json
#	bun.lock
This commit is contained in:
dswbx
2025-06-07 09:38:59 +02:00
26 changed files with 519 additions and 148 deletions

View File

@@ -253,6 +253,11 @@ export class App {
break;
}
});
// call server init if set
if (this.options?.manager?.onServerInit) {
this.options.manager.onServerInit(server);
}
}
}

View File

@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig } from "./config";
import { makeConfig, type CfMakeConfigArgs } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
@@ -23,7 +23,7 @@ describe("cf adapter", () => {
{
connection: { url: DB_URL },
},
{},
$ctx({ DB_URL }),
),
).toEqual({ connection: { url: DB_URL } });
@@ -34,15 +34,15 @@ describe("cf adapter", () => {
connection: { url: env.DB_URL },
}),
},
{
DB_URL,
},
$ctx({ DB_URL }),
),
).toEqual({ connection: { url: DB_URL } });
});
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await makeApp(c, { env: a } as any, o);
},
makeHandler: (c, a, o) => {
return async (request: any) => {
const app = await makeApp(
@@ -50,7 +50,7 @@ describe("cf adapter", () => {
c ?? {
connection: { url: DB_URL },
},
a,
a!,
o,
);
return app.fetch(request);

View File

@@ -9,7 +9,13 @@ import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core";
export type CloudflareEnv = object;
declare global {
namespace Cloudflare {
interface Env {}
}
}
export type CloudflareEnv = Cloudflare.Env;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => {
@@ -17,6 +23,11 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
dobj?: DurableObjectNamespace;
db?: D1Database;
};
d1?: {
session?: boolean;
transport?: "header" | "cookie";
first?: D1SessionConstraint;
};
static?: "kv" | "assets";
key?: string;
keepAliveSeconds?: number;

View File

@@ -1,47 +1,148 @@
/// <reference types="@cloudflare/workers-types" />
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { D1Connection } from "./D1Connection";
import { D1Connection } from "./connection/D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { ExecutionContext } from "hono";
import type { Context, ExecutionContext } from "hono";
import { $console } from "core";
import { setCookie } from "hono/cookie";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
d1_session: {
cookie: "cf_d1_session",
header: "x-cf-d1-session",
},
};
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
env: Env;
ctx?: ExecutionContext;
request?: Request;
};
function getCookieValue(cookies: string | null, name: string) {
if (!cookies) return null;
for (const cookie of cookies.split("; ")) {
const [key, value] = cookie.split("=");
if (key === name && value) {
return decodeURIComponent(value);
}
}
return null;
}
export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
const headerKey = constants.d1_session.header;
const cookieKey = constants.d1_session.cookie;
const transport = config.d1?.transport;
return {
get: (request?: Request): D1SessionBookmark | undefined => {
if (!request || !config.d1?.session) return undefined;
if (!transport || transport === "cookie") {
const cookies = request.headers.get("Cookie");
if (cookies) {
const cookie = getCookieValue(cookies, cookieKey);
if (cookie) {
return cookie;
}
}
}
if (!transport || transport === "header") {
if (request.headers.has(headerKey)) {
return request.headers.get(headerKey) as any;
}
}
return undefined;
},
set: (c: Context, d1?: D1DatabaseSession) => {
if (!d1 || !config.d1?.session) return;
const session = d1.getBookmark();
if (session) {
if (!transport || transport === "header") {
c.header(headerKey, session);
}
if (!transport || transport === "cookie") {
setCookie(c, cookieKey, session, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 60 * 5, // 5 minutes
});
}
}
},
};
}
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
args?: CfMakeConfigArgs<Env>,
) {
if (!media_registered) {
registerMedia(args as any);
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args);
const bindings = config.bindings?.(args);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
$console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
if (binding) {
$console.log(`Using database from env "${binding.key}"`);
db = binding.value;
const appConfig = makeAdapterConfig(config, args?.env);
if (args?.env) {
const bindings = config.bindings?.(args?.env);
const sessionHelper = d1SessionHelper(config);
const sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined;
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
$console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args.env, "D1Database");
if (binding) {
$console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
if (config.d1?.session) {
session = db.withSession(sessionId ?? config.d1?.first);
appConfig.connection = new D1Connection({ binding: session });
} else {
appConfig.connection = new D1Connection({ binding: db });
}
} else {
throw new Error("No database connection given");
}
}
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
if (config.d1?.session) {
appConfig.options = {
...appConfig.options,
manager: {
...appConfig.options?.manager,
onServerInit: (server) => {
server.use(async (c, next) => {
sessionHelper.set(c, session);
await next();
});
},
},
};
}
}

View File

@@ -5,8 +5,8 @@ import type { QB } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig = {
binding: D1Database;
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB;
};
class CustomD1Dialect extends D1Dialect {
@@ -17,22 +17,24 @@ class CustomD1Dialect extends D1Dialect {
}
}
export class D1Connection extends SqliteConnection {
export class D1Connection<
DB extends D1Database | D1DatabaseSession = D1Database,
> extends SqliteConnection {
protected override readonly supported = {
batching: true,
};
constructor(private config: D1ConnectionConfig) {
constructor(private config: D1ConnectionConfig<DB>) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomD1Dialect({ database: config.binding }),
dialect: new CustomD1Dialect({ database: config.binding as D1Database }),
plugins,
});
super(kysely, {}, plugins);
}
get client(): D1Database {
get client(): DB {
return this.config.binding;
}

View File

@@ -1,4 +1,4 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
@@ -12,6 +12,7 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export function d1(config: D1ConnectionConfig) {
return new D1Connection(config);

View File

@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
{ env, ctx, ...args }: Context<Env>,
args: Context<Env>,
) {
const { env, ctx } = args;
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -20,7 +21,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
const app = await createRuntimeApp(
{
...makeConfig(config, env),
...makeConfig(config, args),
initialConfig,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx);
@@ -41,7 +42,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
await config.beforeBuild?.(app);
},
},
{ env, ctx, ...args },
args,
);
if (!cachedConfig) {

View File

@@ -1,13 +1,13 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext } from "../config";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
args?: CfMakeConfigArgs<Env>,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
await config.onBuilt?.(app);
},
},
ctx.env,
ctx,
opts,
);
}

View File

@@ -121,6 +121,7 @@ export class AuthController extends Controller {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
await this.auth.authenticator?.requestCookieRefresh(c);
return c.json({ user });
}

View File

@@ -347,6 +347,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
async logout(c: Context<ServerEnv>) {
$console.info("Logging out");
c.set("auth", undefined);
const cookie = await this.getAuthCookie(c);

View File

@@ -60,11 +60,7 @@ export const auth = (options?: {
}
await next();
if (!skipped) {
// renew cookie if applicable
authenticator?.requestCookieRefresh(c);
}
// @todo: potentially add cookie refresh if content-type html and about to expire
// release
authCtx.skip = false;

View File

@@ -117,7 +117,9 @@ async function detectMimeType(
return;
}
export async function getFileFromContext(c: Context<any>): Promise<File> {
type HonoAnyContext = Context<any, any, any>;
export async function getFileFromContext(c: HonoAnyContext): Promise<File> {
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
if (
@@ -149,7 +151,7 @@ export async function getFileFromContext(c: Context<any>): Promise<File> {
throw new Error("No file found in request");
}
export async function getBodyFromContext(c: Context<any>): Promise<ReadableStream | File> {
export async function getBodyFromContext(c: HonoAnyContext): Promise<ReadableStream | File> {
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
if (

View File

@@ -50,11 +50,11 @@ export class AdminController extends Controller {
}
get basepath() {
return this.options.basepath ?? "/";
return this.options.adminBasepath ?? "/";
}
private withBasePath(route: string = "") {
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
return (this.options.basepath + route).replace(/(?<!:)\/+/g, "/");
}
private withAdminBasePath(route: string = "") {
@@ -80,25 +80,48 @@ export class AdminController extends Controller {
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
login: this.withAdminBasePath("/auth/login"),
register: this.withAdminBasePath("/auth/register"),
logout: this.withAdminBasePath("/auth/logout"),
logout: "/api/auth/logout",
};
hono.use("*", async (c, next) => {
const obj = {
user: c.get("auth")?.user,
logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath,
};
const html = await this.getHtml(obj);
if (!html) {
console.warn("Couldn't generate HTML for admin UI");
// re-casting to void as a return is not required
return c.notFound() as unknown as void;
}
c.set("html", html);
const paths = ["/", "/data/*", "/auth/*", "/media/*", "/flows/*", "/settings/*"];
if (isDebug()) {
paths.push("/test/*");
}
await next();
});
for (const path of paths) {
hono.get(
path,
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
$console.log("redirecting");
return c.redirect(authRoutes.login);
},
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
},
}),
async (c) => {
const obj = {
user: c.get("auth")?.user,
logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath,
};
const html = await this.getHtml(obj);
if (!html) {
console.warn("Couldn't generate HTML for admin UI");
// re-casting to void as a return is not required
return c.notFound() as unknown as void;
}
await auth.authenticator?.requestCookieRefresh(c);
return c.html(html);
},
);
}
if (auth_enabled) {
const redirectRouteParams = [
@@ -126,27 +149,6 @@ export class AdminController extends Controller {
});
}
// @todo: only load known paths
hono.get(
"/*",
permission(SystemPermissions.accessAdmin, {
onDenied: async (c) => {
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
$console.log("redirecting");
return c.redirect(authRoutes.login);
},
}),
permission(SystemPermissions.schemaRead, {
onDenied: async (c) => {
addFlashMessage(c, "You not allowed to read the schema", "warning");
},
}),
async (c) => {
return c.html(c.get("html")!);
},
);
return hono;
}
@@ -194,9 +196,13 @@ export class AdminController extends Controller {
}).then((res) => res.default);
}
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
try {
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
} catch (e) {
$console.warn("Couldn't find assets in manifest", e);
}
}
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";

View File

@@ -331,6 +331,6 @@ export class SystemController extends Controller {
);
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
return hono.all("*", (c) => c.notFound());
return hono;
}
}