reworked html serving, added new permissions for api/auth, updated adapters

This commit is contained in:
dswbx
2024-11-23 11:21:09 +01:00
parent 6077f0e64f
commit 2433833ad0
30 changed files with 418 additions and 298 deletions

View File

@@ -6,7 +6,6 @@
<title>BKND</title> <title>BKND</title>
</head> </head>
<body> <body>
<div id="root"></div>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/ui/main.tsx"></script> <script type="module" src="/src/ui/main.tsx"></script>
</body> </body>

View File

@@ -92,7 +92,7 @@
"entry": ["src/index.ts", "src/ui/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], "entry": ["src/index.ts", "src/ui/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
"minify": true, "minify": true,
"outDir": "dist", "outDir": "dist",
"external": ["bun:test"], "external": ["bun:test", "bknd/dist/manifest.json"],
"sourcemap": true, "sourcemap": true,
"metafile": true, "metafile": true,
"platform": "browser", "platform": "browser",
@@ -109,6 +109,9 @@
"react": ">=18", "react": ">=18",
"react-dom": ">=18" "react-dom": ">=18"
}, },
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@@ -165,9 +168,15 @@
"import": "./dist/adapter/bun/index.js", "import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs" "require": "./dist/adapter/bun/index.cjs"
}, },
"./adapter/node": {
"types": "./dist/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
},
"./dist/static/manifest.json": "./dist/static/.vite/manifest.json", "./dist/static/manifest.json": "./dist/static/.vite/manifest.json",
"./dist/styles.css": "./dist/styles.css", "./dist/styles.css": "./dist/styles.css",
"./dist/index.html": "./dist/static/index.html" "./dist/index.html": "./dist/static/index.html",
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
}, },
"files": [ "files": [
"dist", "dist",

View File

@@ -7,6 +7,7 @@ import {
type Modules type Modules
} from "modules/ModuleManager"; } from "modules/ModuleManager";
import * as SystemPermissions from "modules/permissions"; import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
export type AppPlugin<DB> = (app: App<DB>) => void; export type AppPlugin<DB> = (app: App<DB>) => void;
@@ -58,7 +59,7 @@ export class App<DB = any> {
static create(config: CreateAppConfig) { static create(config: CreateAppConfig) {
let connection: Connection | undefined = undefined; let connection: Connection | undefined = undefined;
if (config.connection instanceof Connection) { if (Connection.isConnection(config.connection)) {
connection = config.connection; connection = config.connection;
} else if (typeof config.connection === "object") { } else if (typeof config.connection === "object") {
switch (config.connection.type) { switch (config.connection.type) {
@@ -66,6 +67,8 @@ export class App<DB = any> {
connection = new LibsqlConnection(config.connection.config); connection = new LibsqlConnection(config.connection.config);
break; break;
} }
} else {
throw new Error(`Unknown connection of type ${typeof config.connection} given.`);
} }
if (!connection) { if (!connection) {
throw new Error("Invalid connection"); throw new Error("Invalid connection");
@@ -79,7 +82,6 @@ export class App<DB = any> {
} }
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) { async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
//console.log("building");
await this.modules.build(); await this.modules.build();
if (options?.sync) { if (options?.sync) {
@@ -136,6 +138,12 @@ export class App<DB = any> {
return this.modules.version(); return this.modules.version();
} }
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.modules.server.route("/", new AdminController(this, config).getController());
return this;
}
toJSON(secrets?: boolean) { toJSON(secrets?: boolean) {
return this.modules.toJSON(secrets); return this.modules.toJSON(secrets);
} }

View File

@@ -1,15 +1,37 @@
import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
let app: App; async function getConnection(conn?: CreateAppConfig["connection"]) {
export function serve(config: CreateAppConfig, distPath?: string) { if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
return new LibsqlConnection(conn.config);
}
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export function serve(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
let app: App;
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = App.create(config); const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
app.emgr.on( app.emgr.on(
"app-built", "app-built",
@@ -20,7 +42,7 @@ export function serve(config: CreateAppConfig, distPath?: string) {
root root
}) })
); );
app.module?.server?.setAdminHtml(await readFile(root + "/index.html", "utf-8")); app.registerAdminController();
}, },
"sync" "sync"
); );
@@ -28,6 +50,6 @@ export function serve(config: CreateAppConfig, distPath?: string) {
await app.build(); await app.build();
} }
return app.modules.server.fetch(req); return app.fetch(req);
}; };
} }

View File

@@ -113,11 +113,10 @@ async function getFresh(config: BkndConfig, { env, html }: Context) {
"sync" "sync"
); );
} }
await app.build(); await app.build();
if (config?.setAdminHtml !== false) { if (config.setAdminHtml) {
app.module.server.setAdminHtml(html); app.registerAdminController({ html });
} }
return app; return app;
@@ -147,6 +146,7 @@ async function getCached(
await cache.delete(key); await cache.delete(key);
return c.json({ message: "Cache cleared" }); return c.json({ message: "Cache cleared" });
}); });
app.registerAdminController({ html });
config.onBuilt!(app); config.onBuilt!(app);
}, },
@@ -163,13 +163,13 @@ async function getCached(
); );
await app.build(); await app.build();
if (!cachedConfig) {
saveConfig(app.toJSON(true)); if (config.setAdminHtml) {
app.registerAdminController({ html });
} }
//addAssetsRoute(app, manifest); if (!cachedConfig) {
if (config?.setAdminHtml !== false) { saveConfig(app.toJSON(true));
app.module.server.setAdminHtml(html);
} }
return app; return app;
@@ -212,10 +212,6 @@ export class DurableBkndApp extends DurableObject {
colo: context.colo colo: context.colo
}); });
}); });
if (options?.setAdminHtml !== false) {
app.module.server.setAdminHtml(options.html);
}
}, },
"sync" "sync"
); );

View File

@@ -0,0 +1,82 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { App, type CreateAppConfig } from "bknd";
import { LibsqlConnection } from "bknd/data";
import type { Manifest } from "vite";
async function getConnection(conn?: CreateAppConfig["connection"]) {
if (conn) {
if (LibsqlConnection.isConnection(conn)) {
return conn;
}
return new LibsqlConnection(conn.config);
}
const createClient = await import("@libsql/client/node").then((m) => m.createClient);
if (!createClient) {
throw new Error('libsql client not found, you need to install "@libsql/client/node"');
}
console.log("Using in-memory database");
return new LibsqlConnection(createClient({ url: ":memory:" }));
}
export type NodeAdapterOptions = {
relativeDistPath?: string;
viteManifest?: Manifest;
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
};
export function serve(_config: Partial<CreateAppConfig> = {}, options: NodeAdapterOptions = {}) {
const root = path.relative(
process.cwd(),
path.resolve(options.relativeDistPath ?? "./node_modules/bknd/dist", "static")
);
let app: App;
honoServe(
{
port: options.port ?? 1337,
hostname: options.hostname,
fetch: async (req: Request) => {
if (!app) {
const connection = await getConnection(_config.connection);
app = App.create({
..._config,
connection
});
const viteManifest =
options.viteManifest ??
JSON.parse(await readFile(path.resolve(root, ".vite/manifest.json"), "utf-8"));
app.emgr.on(
"app-built",
async () => {
app.modules.server.get(
"/assets/*",
serveStatic({
root
})
);
app.registerAdminController({
viteManifest
});
},
"sync"
);
await app.build();
}
return app.fetch(req);
}
},
options.listener
);
}

View File

@@ -31,7 +31,7 @@ function setAppBuildListener(app: App, config: BkndConfig, html: string) {
"app-built", "app-built",
async () => { async () => {
await config.onBuilt?.(app); await config.onBuilt?.(app);
app.module.server.setAdminHtml(html); app.registerAdminController();
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" })); app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
}, },
"sync" "sync"

View File

@@ -34,7 +34,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async strategies() { async strategies() {
return this.get<{ strategies: AppAuthSchema["strategies"] }>(["strategies"]); return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
} }
async logout() {} async logout() {}

View File

@@ -1,31 +1,29 @@
import type { AppAuth } from "auth"; import type { AppAuth } from "auth";
import type { ClassController } from "core"; import type { ClassController } from "core";
import { Hono, type MiddlewareHandler } from "hono"; import { Hono, type MiddlewareHandler } from "hono";
import * as SystemPermissions from "modules/permissions";
export class AuthController implements ClassController { export class AuthController implements ClassController {
constructor(private auth: AppAuth) {} constructor(private auth: AppAuth) {}
getMiddleware: MiddlewareHandler = async (c, next) => { get guard() {
// @todo: consider adding app name to the payload, because user is not refetched return this.auth.ctx.guard;
}
//try { getMiddleware: MiddlewareHandler = async (c, next) => {
let token: string | undefined;
if (c.req.raw.headers.has("Authorization")) { if (c.req.raw.headers.has("Authorization")) {
const bearerHeader = String(c.req.header("Authorization")); const bearerHeader = String(c.req.header("Authorization"));
const token = bearerHeader.replace("Bearer ", ""); token = bearerHeader.replace("Bearer ", "");
const verified = await this.auth.authenticator.verify(token); }
if (token) {
// @todo: don't extract user from token, but from the database or cache // @todo: don't extract user from token, but from the database or cache
await this.auth.authenticator.verify(token);
this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser()); this.auth.ctx.guard.setUserContext(this.auth.authenticator.getUser());
/*console.log("jwt verified?", {
verified,
auth: this.auth.authenticator.isUserLoggedIn()
});*/
} else { } else {
this.auth.authenticator.__setUserNull(); this.auth.authenticator.__setUserNull();
} }
/* } catch (e) {
this.auth.authenticator.__setUserNull();
}*/
await next(); await next();
}; };
@@ -49,7 +47,8 @@ export class AuthController implements ClassController {
}); });
hono.get("/strategies", async (c) => { hono.get("/strategies", async (c) => {
return c.json({ strategies: this.auth.toJSON(false).strategies }); const { strategies, basepath } = this.auth.toJSON(false);
return c.json({ strategies, basepath });
}); });
return hono; return hono;

View File

@@ -11,6 +11,8 @@ export type GuardConfig = {
enabled?: boolean; enabled?: boolean;
}; };
const debug = false;
export class Guard { export class Guard {
permissions: Permission[]; permissions: Permission[];
user?: GuardUserContext; user?: GuardUserContext;
@@ -96,12 +98,12 @@ export class Guard {
if (this.user && typeof this.user.role === "string") { if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role); const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) { if (role) {
console.log("guard: role found", this.user.role); debug && console.log("guard: role found", this.user.role);
return role; return role;
} }
} }
console.log("guard: role not found", this.user, this.user?.role); debug && console.log("guard: role not found", this.user, this.user?.role);
return this.getDefaultRole(); return this.getDefaultRole();
} }
@@ -109,10 +111,14 @@ export class Guard {
return this.roles?.find((role) => role.is_default); return this.roles?.find((role) => role.is_default);
} }
isEnabled() {
return this.config?.enabled === true;
}
hasPermission(permission: Permission): boolean; hasPermission(permission: Permission): boolean;
hasPermission(name: string): boolean; hasPermission(name: string): boolean;
hasPermission(permissionOrName: Permission | string): boolean { hasPermission(permissionOrName: Permission | string): boolean {
if (this.config?.enabled !== true) { if (!this.isEnabled()) {
//console.log("guard not enabled, allowing"); //console.log("guard not enabled, allowing");
return true; return true;
} }
@@ -126,10 +132,10 @@ export class Guard {
const role = this.getUserRole(); const role = this.getUserRole();
if (!role) { if (!role) {
console.log("guard: role not found, denying"); debug && console.log("guard: role not found, denying");
return false; return false;
} else if (role.implicit_allow === true) { } else if (role.implicit_allow === true) {
console.log("guard: role implicit allow, allowing"); debug && console.log("guard: role implicit allow, allowing");
return true; return true;
} }
@@ -137,6 +143,7 @@ export class Guard {
(rolePermission) => rolePermission.permission.name === name (rolePermission) => rolePermission.permission.name === name
); );
debug &&
console.log("guard: rolePermission, allowing?", { console.log("guard: rolePermission, allowing?", {
permission: name, permission: name,
role: role.name, role: role.name,

View File

@@ -7,6 +7,7 @@ export const config: CliCommand = (program) => {
.description("get default config") .description("get default config")
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .action((options) => {
console.log(getDefaultConfig(options.pretty)); const config = getDefaultConfig();
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
}); });
}; };

View File

@@ -1,9 +1,9 @@
import type { Config } from "@libsql/client/node"; import type { Config } from "@libsql/client/node";
import { App } from "App"; import { App } from "App";
import type { BkndConfig } from "adapter"; import type { BkndConfig } from "adapter";
import type { CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import type { Connection } from "data"; import type { Connection } from "data";
import type { CliCommand } from "../../types";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
@@ -48,14 +48,13 @@ type MakeAppConfig = {
}; };
async function makeApp(config: MakeAppConfig) { async function makeApp(config: MakeAppConfig) {
const html = await getHtml();
const app = new App(config.connection); const app = new App(config.connection);
app.emgr.on( app.emgr.on(
"app-built", "app-built",
async () => { async () => {
await attachServeStatic(app, config.server?.platform ?? "node"); await attachServeStatic(app, config.server?.platform ?? "node");
app.module.server.setAdminHtml(html); app.registerAdminController({ html: await getHtml() });
if (config.onBuilt) { if (config.onBuilt) {
await config.onBuilt(app); await config.onBuilt(app);
@@ -70,14 +69,13 @@ async function makeApp(config: MakeAppConfig) {
export async function makeConfigApp(config: BkndConfig, platform?: Platform) { export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app; const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
const html = await getHtml();
const app = App.create(appConfig); const app = App.create(appConfig);
app.emgr.on( app.emgr.on(
"app-built", "app-built",
async () => { async () => {
await attachServeStatic(app, platform ?? "node"); await attachServeStatic(app, platform ?? "node");
app.module.server.setAdminHtml(html); app.registerAdminController({ html: await getHtml() });
if (config.onBuilt) { if (config.onBuilt) {
await config.onBuilt(app); await config.onBuilt(app);

View File

@@ -7,6 +7,7 @@ export const schema: CliCommand = (program) => {
.description("get schema") .description("get schema")
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .action((options) => {
console.log(getDefaultSchema(options.pretty)); const schema = getDefaultSchema();
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
}); });
}; };

View File

@@ -15,7 +15,7 @@ import {
import { Hono } from "hono"; import { Hono } from "hono";
import type { Handler } from "hono/types"; import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules"; import type { ModuleBuildContext } from "modules";
import { AppData } from "../AppData"; import * as SystemPermissions from "modules/permissions";
import { type AppDataConfig, FIELDS } from "../data-schema"; import { type AppDataConfig, FIELDS } from "../data-schema";
export class DataController implements ClassController { export class DataController implements ClassController {
@@ -89,12 +89,10 @@ export class DataController implements ClassController {
return func; return func;
} }
// add timing hono.use("*", async (c, next) => {
/*hono.use("*", async (c, next) => { this.ctx.guard.throwUnlessGranted(SystemPermissions.api);
startTime(c, "data");
await next(); await next();
endTime(c, "data"); });
});*/
// info // info
hono.get( hono.get(

View File

@@ -42,6 +42,7 @@ export type DbFunctions = {
}; };
export abstract class Connection { export abstract class Connection {
cls = "bknd:connection";
kysely: Kysely<any>; kysely: Kysely<any>;
constructor( constructor(
@@ -52,6 +53,15 @@ export abstract class Connection {
this.kysely = kysely; this.kysely = kysely;
} }
/**
* This is a helper function to manage Connection classes
* coming from different places
* @param conn
*/
static isConnection(conn: any): conn is Connection {
return conn?.cls === "bknd:connection";
}
getIntrospector(): ConnectionIntrospector { getIntrospector(): ConnectionIntrospector {
return this.kysely.introspection as ConnectionIntrospector; return this.kysely.introspection as ConnectionIntrospector;
} }

View File

@@ -36,7 +36,7 @@ export class EntityManager<DB> {
relations.forEach((relation) => this.addRelation(relation)); relations.forEach((relation) => this.addRelation(relation));
indices.forEach((index) => this.addIndex(index)); indices.forEach((index) => this.addIndex(index));
if (!(connection instanceof Connection)) { if (!Connection.isConnection(connection)) {
throw new UnableToConnectException(""); throw new UnableToConnectException("");
} }

View File

@@ -425,19 +425,19 @@ export class ModuleManager {
} }
} }
export function getDefaultSchema(pretty = false) { export function getDefaultSchema() {
const schema = { const schema = {
type: "object", type: "object",
...transformObject(MODULES, (module) => module.prototype.getSchema()) ...transformObject(MODULES, (module) => module.prototype.getSchema())
}; };
return JSON.stringify(schema, null, pretty ? 2 : undefined); return schema as any;
} }
export function getDefaultConfig(pretty = false): ModuleConfigs { export function getDefaultConfig(): ModuleConfigs {
const config = transformObject(MODULES, (module) => { const config = transformObject(MODULES, (module) => {
return Default(module.prototype.getSchema(), {}); return Default(module.prototype.getSchema(), {});
}); });
return JSON.stringify(config, null, pretty ? 2 : undefined) as any; return config as any;
} }

View File

@@ -1,5 +1,7 @@
import { Permission } from "core"; import { Permission } from "core";
export const admin = new Permission("system.admin");
export const api = new Permission("system.api");
export const configRead = new Permission("system.config.read"); export const configRead = new Permission("system.config.read");
export const configReadSecrets = new Permission("system.config.read.secrets"); export const configReadSecrets = new Permission("system.config.read.secrets");
export const configWrite = new Permission("system.config.write"); export const configWrite = new Permission("system.config.write");

View File

@@ -0,0 +1,104 @@
/** @jsxImportSource hono/jsx */
import type { App } from "App";
import { type ClassController, isDebug } from "core";
import { Hono } from "hono";
import { html, raw } from "hono/html";
import { Fragment } from "hono/jsx";
import * as SystemPermissions from "modules/permissions";
import type { Manifest } from "vite";
const viteInject = `
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
`;
export type AdminControllerOptions = {
html?: string;
viteManifest?: Manifest;
};
export class AdminController implements ClassController {
constructor(
private readonly app: App,
private options: AdminControllerOptions = {}
) {}
get ctx() {
return this.app.modules.ctx();
}
getController(): Hono {
const hono = new Hono();
const configs = this.app.modules.configs();
const basepath = (String(configs.server.admin.basepath) + "/").replace(/\/+$/, "/");
this.ctx.server.get(basepath + "*", async (c) => {
if (this.options.html) {
return c.html(this.options.html);
}
// @todo: implement guard redirect once cookie sessions arrive
const isProd = !isDebug();
let script: string | undefined;
let css: string[] = [];
if (isProd) {
const manifest: Manifest = this.options.viteManifest
? this.options.viteManifest
: isProd
? // @ts-ignore cases issues when building types
await import("bknd/dist/manifest.json", { assert: { type: "json" } }).then(
(m) => m.default
)
: {};
//console.log("manifest", manifest, manifest["index.html"]);
const entry = Object.values(manifest).find((f: any) => f.isEntry === true);
if (!entry) {
// do something smart
return;
}
script = "/" + entry.file;
css = entry.css?.map((c: string) => "/" + c) ?? [];
}
return c.html(
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<title>BKND</title>
{isProd ? (
<Fragment>
<script type="module" CrossOrigin src={script} />
{css.map((c) => (
<link rel="stylesheet" CrossOrigin href={c} key={c} />
))}
</Fragment>
) : (
<Fragment>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: I know what I do here :) */}
<script type="module" dangerouslySetInnerHTML={{ __html: viteInject }} />
<script type="module" src="/@vite/client" />
</Fragment>
)}
</head>
<body>
<div id="app" />
{!isProd && <script type="module" src="/src/ui/main.tsx" />}
</body>
</html>
);
});
return hono;
}
}

View File

@@ -1,110 +0,0 @@
import type { ClassController } from "core";
import { SimpleRenderer } from "core";
import { FetchTask, Flow, LogTask } from "flows";
import { Hono } from "hono";
import { endTime, startTime } from "hono/timing";
import type { App } from "../../App";
export class AppController implements ClassController {
constructor(
private readonly app: App,
private config: any = {}
) {}
getController(): Hono {
const hono = new Hono();
// @todo: add test endpoints
hono
.get("/config", (c) => {
return c.json(this.app.toJSON());
})
.get("/ping", (c) => {
//console.log("c", c);
try {
// @ts-ignore @todo: fix with env
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
const cf = {
colo: context.colo,
city: context.city,
postal: context.postalCode,
region: context.region,
regionCode: context.regionCode,
continent: context.continent,
country: context.country,
eu: context.isEUCountry,
lat: context.latitude,
lng: context.longitude,
timezone: context.timezone
};
return c.json({ pong: true, cf, another: 6 });
} catch (e) {
return c.json({ pong: true, cf: null });
}
});
// test endpoints
if (this.config?.registerTest) {
hono.get("/test/kv", async (c) => {
// @ts-ignore
const cache = c.env!.CACHE as KVNamespace;
startTime(c, "kv-get");
const value: any = await cache.get("count");
endTime(c, "kv-get");
console.log("value", value);
startTime(c, "kv-put");
if (!value) {
await cache.put("count", "1");
} else {
await cache.put("count", (Number(value) + 1).toString());
}
endTime(c, "kv-put");
let cf: any = {};
// @ts-ignore
if ("cf" in c.req.raw) {
cf = {
// @ts-ignore
colo: c.req.raw.cf?.colo
};
}
return c.json({ pong: true, value, cf });
});
hono.get("/test/flow", async (c) => {
const first = new LogTask("Task 0");
const second = new LogTask("Task 1");
const third = new LogTask("Task 2", { delay: 250 });
const fourth = new FetchTask("Fetch Something", {
url: "https://jsonplaceholder.typicode.com/todos/1"
});
const fifth = new LogTask("Task 4"); // without connection
const flow = new Flow("flow", [first, second, third, fourth, fifth]);
flow.task(first).asInputFor(second);
flow.task(first).asInputFor(third);
flow.task(fourth).asOutputFor(third);
flow.setRespondingTask(fourth);
const execution = flow.createExecution();
await execution.start();
const results = flow.tasks.map((t) => t.toJSON());
return c.json({ results, response: execution.getResponse() });
});
hono.get("/test/template", async (c) => {
const renderer = new SimpleRenderer({ var: 123 });
const template = "Variable: {{ var }}";
return c.text(await renderer.render(template));
});
}
return hono;
}
}

View File

@@ -4,6 +4,7 @@ import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { timing } from "hono/timing"; import { timing } from "hono/timing";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import * as SystemPermissions from "modules/permissions";
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"]; const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
export const serverConfigSchema = Type.Object( export const serverConfigSchema = Type.Object(
@@ -49,7 +50,7 @@ export type AppServerConfig = Static<typeof serverConfigSchema>;
}*/ }*/
export class AppServer extends Module<typeof serverConfigSchema> { export class AppServer extends Module<typeof serverConfigSchema> {
private admin_html?: string; //private admin_html?: string;
override getRestrictedPaths() { override getRestrictedPaths() {
return []; return [];
@@ -64,12 +65,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
} }
override async build() { override async build() {
//this.client.use(timing());
/*this.client.use("*", async (c, next) => {
console.log(`[${c.req.method}] ${c.req.url}`);
await next();
});*/
this.client.use( this.client.use(
"*", "*",
cors({ cors({
@@ -79,18 +74,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
}) })
); );
/*this.client.use(async (c, next) => {
c.res.headers.set("X-Powered-By", "BKND");
try {
c.res.headers.set("X-Colo", c.req.raw.cf.colo);
} catch (e) {}
await next();
});
this.client.use(async (c, next) => {
console.log(`[${c.req.method}] ${c.req.url}`);
await next();
});*/
this.client.onError((err, c) => { this.client.onError((err, c) => {
//throw err; //throw err;
console.error(err); console.error(err);
@@ -124,18 +107,31 @@ export class AppServer extends Module<typeof serverConfigSchema> {
this.setBuilt(); this.setBuilt();
} }
setAdminHtml(html: string) { /*setAdminHtml(html: string) {
this.admin_html = html; this.admin_html = html;
const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/"); const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/");
const allowed_prefix = basepath + "auth";
const login_path = basepath + "auth/login";
this.client.get(basepath + "*", async (c, next) => { this.client.get(basepath + "*", async (c, next) => {
const path = new URL(c.req.url).pathname;
if (!path.startsWith(allowed_prefix)) {
console.log("guard check permissions");
try {
this.ctx.guard.throwUnlessGranted(SystemPermissions.admin);
} catch (e) {
return c.redirect(login_path);
}
}
return c.html(this.admin_html!); return c.html(this.admin_html!);
}); });
} }
getAdminHtml() { getAdminHtml() {
return this.admin_html; return this.admin_html;
} }*/
override toJSON(secrets?: boolean) { override toJSON(secrets?: boolean) {
return this.config; return this.config;

View File

@@ -98,7 +98,12 @@ export class SystemController implements ClassController {
// you must explicitly set force to override existing values // you must explicitly set force to override existing values
// because omitted values gets removed // because omitted values gets removed
if (force === true) { if (force === true) {
await this.app.mutateConfig(module).set(value); // force overwrite defined keys
const newConfig = {
...this.app.module[module].config,
...value
};
await this.app.mutateConfig(module).set(newConfig);
} else { } else {
await this.app.mutateConfig(module).patch("", value); await this.app.mutateConfig(module).patch("", value);
} }
@@ -287,7 +292,7 @@ export class SystemController implements ClassController {
hono.get("/openapi.json", async (c) => { hono.get("/openapi.json", async (c) => {
//const config = this.app.toJSON(); //const config = this.app.toJSON();
const config = JSON.parse(getDefaultConfig() as any); const config = getDefaultConfig();
return c.json(generateOpenAPI(config)); return c.json(generateOpenAPI(config));
}); });

View File

@@ -1,4 +1,6 @@
import { createContext, useContext, useEffect, useRef, useState } from "react"; import { notifications } from "@mantine/notifications";
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
import type { ModuleConfigs, ModuleSchemas } from "../../modules"; import type { ModuleConfigs, ModuleSchemas } from "../../modules";
import { useClient } from "./ClientProvider"; import { useClient } from "./ClientProvider";
import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { type TSchemaActions, getSchemaActions } from "./schema/actions";
@@ -22,18 +24,47 @@ export function BkndProvider({
children children
}: { includeSecrets?: boolean; children: any }) { }: { includeSecrets?: boolean; children: any }) {
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets); const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
const [schema, setSchema] = useState<BkndContext>(); const [schema, setSchema] =
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
const [fetched, setFetched] = useState(false);
const errorShown = useRef<boolean>();
const client = useClient(); const client = useClient();
async function fetchSchema(_includeSecrets: boolean = false) { async function fetchSchema(_includeSecrets: boolean = false) {
if (withSecrets) return; if (withSecrets) return;
const { body } = await client.api.system.readSchema({ const { body, res } = await client.api.system.readSchema({
config: true, config: true,
secrets: _includeSecrets secrets: _includeSecrets
}); });
console.log("--schema fetched", body);
setSchema(body as any); if (!res.ok) {
if (errorShown.current) return;
errorShown.current = true;
notifications.show({
title: "Failed to fetch schema",
// @ts-ignore
message: body.error,
color: "red",
position: "top-right",
autoClose: false,
withCloseButton: true
});
}
const schema = res.ok
? body
: ({
version: 0,
schema: getDefaultSchema(),
config: getDefaultConfig(),
permissions: []
} as any);
startTransition(() => {
setSchema(schema);
setWithSecrets(_includeSecrets); setWithSecrets(_includeSecrets);
setFetched(true);
});
} }
async function requireSecrets() { async function requireSecrets() {
@@ -46,8 +77,8 @@ export function BkndProvider({
fetchSchema(includeSecrets); fetchSchema(includeSecrets);
}, []); }, []);
if (!schema?.schema) return null; if (!fetched || !schema) return null;
const app = new AppReduced(schema.config as any); const app = new AppReduced(schema?.config as any);
const actions = getSchemaActions({ client, setSchema }); const actions = getSchemaActions({ client, setSchema });
@@ -64,20 +95,3 @@ export function useBknd({ withSecrets }: { withSecrets?: boolean } = {}): BkndCo
return ctx; return ctx;
} }
/*
type UseSchemaForType<Key extends keyof ModuleSchemas> = {
version: number;
schema: ModuleSchemas[Key];
config: ModuleConfigs[Key];
};
export function useSchemaFor<Key extends keyof ModuleConfigs>(module: Key): UseSchemaForType<Key> {
//const app = useApp();
const { version, schema, config } = useSchema();
return {
version,
schema: schema[module],
config: config[module]
};
}*/

View File

@@ -82,11 +82,11 @@ export const useAuth = (options?: { baseUrl?: string }): UseAuth => {
}; };
}; };
export const useAuthStrategies = (options?: { baseUrl?: string }): { type AuthStrategyData = Pick<AppAuthSchema, "strategies" | "basepath">;
strategies: AppAuthSchema["strategies"]; export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
loading: boolean; loading: boolean;
} => { } => {
const [strategies, setStrategies] = useState<AppAuthSchema["strategies"]>(); const [data, setData] = useState<AuthStrategyData>();
const ctxBaseUrl = useBaseUrl(); const ctxBaseUrl = useBaseUrl();
const api = new Api({ const api = new Api({
host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl, host: options?.baseUrl ? options?.baseUrl : ctxBaseUrl,
@@ -98,10 +98,10 @@ export const useAuthStrategies = (options?: { baseUrl?: string }): {
const res = await api.auth.strategies(); const res = await api.auth.strategies();
console.log("res", res); console.log("res", res);
if (res.res.ok) { if (res.res.ok) {
setStrategies(res.body.strategies); setData(res.body);
} }
})(); })();
}, [options?.baseUrl]); }, [options?.baseUrl]);
return { strategies, loading: !strategies }; return { strategies: data?.strategies, basepath: data?.basepath, loading: !data };
}; };

View File

@@ -50,14 +50,18 @@ export class AppQueryClient {
return this.api.getAuthState(); return this.api.getAuthState();
}, },
verify: async () => { verify: async () => {
console.log("verifiying"); try {
//console.log("verifiying");
const res = await this.api.auth.me(); const res = await this.api.auth.me();
console.log("verifying result", res); //console.log("verifying result", res);
if (!res.res.ok) { if (!res.res.ok || !res.body.user) {
throw new Error();
}
this.api.markAuthVerified(true);
} catch (e) {
this.api.markAuthVerified(false); this.api.markAuthVerified(false);
this.api.updateToken(undefined); this.api.updateToken(undefined);
} else {
this.api.markAuthVerified(true);
} }
} }
}; };

View File

@@ -8,6 +8,7 @@ export type AppType = ReturnType<App["toJSON"]>;
/** /**
* Reduced version of the App class for frontend use * Reduced version of the App class for frontend use
* @todo: remove this class
*/ */
export class AppReduced { export class AppReduced {
// @todo: change to record // @todo: change to record
@@ -16,7 +17,7 @@ export class AppReduced {
private _flows: Flow[] = []; private _flows: Flow[] = [];
constructor(protected appJson: AppType) { constructor(protected appJson: AppType) {
console.log("received appjson", appJson); //console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => { this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return AppData.constructEntity(name, entity); return AppData.constructEntity(name, entity);

View File

@@ -25,7 +25,7 @@ export function AuthLogin() {
//console.log("search", token, "/api/auth/google?redirect=" + window.location.href); //console.log("search", token, "/api/auth/google?redirect=" + window.location.href);
const auth = useAuth(); const auth = useAuth();
const { strategies, loading } = useAuthStrategies(); const { strategies, basepath, loading } = useAuthStrategies();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -92,7 +92,7 @@ export function AuthLogin() {
variant="outline" variant="outline"
className="justify-center" className="justify-center"
onClick={() => { onClick={() => {
window.location.href = `/api/auth/${name}/login?redirect=${window.location.href}`; window.location.href = `${basepath}/${name}/login?redirect=${window.location.href}`;
}} }}
> >
Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)} Continue with {ucFirstAllSnakeToPascalWithSpaces(oauth.name)}

View File

@@ -35,5 +35,5 @@
} }
}, },
"include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"], "include": ["./src/**/*.ts", "./src/**/*.tsx", "./env.d.ts"],
"exclude": ["node_modules", "dist/**/*", "../examples/bun"] "exclude": ["node_modules", "./dist/**/*", "../examples/bun"]
} }

View File

@@ -46,3 +46,9 @@ await build({
await build({ await build({
...baseConfig("bun") ...baseConfig("bun")
}); });
await build({
...baseConfig("node"),
format: ["esm", "cjs"],
platform: "node"
});

View File

@@ -1,4 +1,3 @@
import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { createClient } from "@libsql/client/node"; import { createClient } from "@libsql/client/node";
import { App, type BkndConfig, type CreateAppConfig } from "./src"; import { App, type BkndConfig, type CreateAppConfig } from "./src";
@@ -6,61 +5,6 @@ import { LibsqlConnection } from "./src/data";
import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter"; import { StorageLocalAdapter } from "./src/media/storage/adapters/StorageLocalAdapter";
import { registries } from "./src/modules/registries"; import { registries } from "./src/modules/registries";
async function getHtml() {
return readFile("index.html", "utf8");
}
function addViteScripts(html: string) {
return html.replace(
"<head>",
`<script type="module">
import RefreshRuntime from "/@react-refresh"
RefreshRuntime.injectIntoGlobalHook(window)
window.$RefreshReg$ = () => {}
window.$RefreshSig$ = () => (type) => type
window.__vite_plugin_react_preamble_installed__ = true
</script>
<script type="module" src="/@vite/client"></script>
`
);
}
function createApp(config: BkndConfig, env: any) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
return App.create(create_config as CreateAppConfig);
}
function setAppBuildListener(app: App, config: BkndConfig, html: string) {
app.emgr.on(
"app-built",
async () => {
await config.onBuilt?.(app as any);
app.module.server.setAdminHtml(html);
app.module.server.client.get("/assets/!*", serveStatic({ root: "./" }));
},
"sync"
);
}
export async function serveFresh(config: BkndConfig, _html?: string) {
let html = _html;
if (!html) {
html = await getHtml();
}
html = addViteScripts(html);
return {
async fetch(request: Request, env: any) {
const app = createApp(config, env);
setAppBuildListener(app, config, html);
await app.build();
return app.fetch(request, env);
}
};
}
registries.media.add("local", { registries.media.add("local", {
cls: StorageLocalAdapter, cls: StorageLocalAdapter,
schema: StorageLocalAdapter.prototype.getSchema() schema: StorageLocalAdapter.prototype.getSchema()
@@ -72,11 +16,35 @@ const connection = new LibsqlConnection(
}) })
); );
const app = await serveFresh({ function createApp(config: BkndConfig, env: any) {
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
return App.create(create_config as CreateAppConfig);
}
export async function serveFresh(config: BkndConfig) {
return {
async fetch(request: Request, env: any) {
const app = createApp(config, env);
app.emgr.on(
"app-built",
async () => {
await config.onBuilt?.(app as any);
app.registerAdminController();
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
},
"sync"
);
await app.build();
return app.fetch(request, env);
}
};
}
export default await serveFresh({
app: { app: {
connection connection
}, },
setAdminHtml: true setAdminHtml: true
}); });
export default app;