mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
import {
|
|
objectEach,
|
|
transformObject,
|
|
McpServer,
|
|
type s,
|
|
SecretSchema,
|
|
setPath,
|
|
mark,
|
|
$console,
|
|
} from "bknd/utils";
|
|
import { DebugLogger } from "core/utils/DebugLogger";
|
|
import { Guard } from "auth/authorize/Guard";
|
|
import { env } from "core/env";
|
|
import { EventManager, Event } from "core/events";
|
|
import type { Connection } from "data/connection";
|
|
import { EntityManager } from "data/entities/EntityManager";
|
|
import { Hono } from "hono";
|
|
import type { ServerEnv } from "./Controller";
|
|
import { Module, type ModuleBuildContext } from "./Module";
|
|
import { ModuleHelper } from "./ModuleHelper";
|
|
import { AppServer } from "modules/server/AppServer";
|
|
import { AppAuth } from "auth/AppAuth";
|
|
import { AppData } from "data/AppData";
|
|
import { AppFlows } from "flows/AppFlows";
|
|
import { AppMedia } from "media/AppMedia";
|
|
import type { PartialRec } from "core/types";
|
|
import { mergeWith, pick } from "lodash-es";
|
|
|
|
export type { ModuleBuildContext };
|
|
|
|
export const MODULES = {
|
|
server: AppServer,
|
|
data: AppData,
|
|
auth: AppAuth,
|
|
media: AppMedia,
|
|
flows: AppFlows,
|
|
} as const;
|
|
|
|
// get names of MODULES as an array
|
|
export const MODULE_NAMES = Object.keys(MODULES) as ModuleKey[];
|
|
|
|
export type ModuleKey = keyof typeof MODULES;
|
|
export type Modules = {
|
|
[K in keyof typeof MODULES]: InstanceType<(typeof MODULES)[K]>;
|
|
};
|
|
|
|
export type ModuleSchemas = {
|
|
[K in keyof typeof MODULES]: ReturnType<(typeof MODULES)[K]["prototype"]["getSchema"]>;
|
|
};
|
|
|
|
export type ModuleConfigs = {
|
|
[K in keyof ModuleSchemas]: s.Static<ModuleSchemas[K]>;
|
|
};
|
|
|
|
export type InitialModuleConfigs = { version?: number } & PartialRec<ModuleConfigs>;
|
|
|
|
enum Verbosity {
|
|
silent = 0,
|
|
error = 1,
|
|
log = 2,
|
|
}
|
|
|
|
export type ModuleManagerOptions = {
|
|
initial?: InitialModuleConfigs;
|
|
eventManager?: EventManager<any>;
|
|
onUpdated?: <Module extends keyof Modules>(
|
|
module: Module,
|
|
config: ModuleConfigs[Module],
|
|
) => Promise<void>;
|
|
// triggered when no config table existed
|
|
onFirstBoot?: () => Promise<void>;
|
|
// base path for the hono instance
|
|
basePath?: string;
|
|
// callback after server was created
|
|
onServerInit?: (server: Hono<ServerEnv>) => void;
|
|
// doesn't perform validity checks for given/fetched config
|
|
skipValidation?: boolean;
|
|
// runs when initial config provided on a fresh database
|
|
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
|
// called right after modules are built, before finish
|
|
onModulesBuilt?: (ctx: ModuleBuildContext) => Promise<void>;
|
|
// whether to store secrets in the database
|
|
storeSecrets?: boolean;
|
|
// provided secrets
|
|
secrets?: Record<string, any>;
|
|
/** @deprecated */
|
|
verbosity?: Verbosity;
|
|
};
|
|
|
|
const debug_modules = env("modules_debug", false);
|
|
|
|
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
|
export class ModuleManagerConfigUpdateEvent<
|
|
Module extends keyof ModuleConfigs,
|
|
> extends ModuleManagerEvent<{
|
|
module: Module;
|
|
config: ModuleConfigs[Module];
|
|
}> {
|
|
static override slug = "mm-config-update";
|
|
}
|
|
export class ModuleManagerSecretsExtractedEvent extends ModuleManagerEvent<{
|
|
secrets: Record<string, any>;
|
|
}> {
|
|
static override slug = "mm-secrets-extracted";
|
|
}
|
|
export const ModuleManagerEvents = {
|
|
ModuleManagerConfigUpdateEvent,
|
|
ModuleManagerSecretsExtractedEvent,
|
|
};
|
|
|
|
// @todo: cleanup old diffs on upgrade
|
|
// @todo: cleanup multiple backups on upgrade
|
|
export class ModuleManager {
|
|
static Events = ModuleManagerEvents;
|
|
|
|
protected modules: Modules;
|
|
// ctx for modules
|
|
em!: EntityManager;
|
|
server!: Hono<ServerEnv>;
|
|
emgr!: EventManager;
|
|
guard!: Guard;
|
|
mcp!: ModuleBuildContext["mcp"];
|
|
|
|
protected _built = false;
|
|
|
|
protected logger: DebugLogger;
|
|
|
|
constructor(
|
|
protected readonly connection: Connection,
|
|
public options?: Partial<ModuleManagerOptions>,
|
|
) {
|
|
this.modules = {} as Modules;
|
|
this.emgr = new EventManager({ ...ModuleManagerEvents });
|
|
this.logger = new DebugLogger(debug_modules);
|
|
|
|
const config = options?.initial ?? {};
|
|
if (options?.skipValidation) {
|
|
mark(config, true);
|
|
}
|
|
|
|
this.createModules(config);
|
|
}
|
|
|
|
protected onModuleConfigUpdated(key: string, config: any) {}
|
|
|
|
private createModules(initial: PartialRec<ModuleConfigs>) {
|
|
this.logger.context("createModules").log("creating modules");
|
|
try {
|
|
const context = this.ctx(true);
|
|
|
|
for (const key in MODULES) {
|
|
const moduleConfig = initial && key in initial ? initial[key] : {};
|
|
const module = new MODULES[key](moduleConfig, context) as Module;
|
|
module.setListener(async (c) => {
|
|
await this.onModuleConfigUpdated(key, c);
|
|
});
|
|
|
|
this.modules[key] = module;
|
|
}
|
|
this.logger.log("modules created");
|
|
} catch (e) {
|
|
this.logger.log("failed to create modules", e);
|
|
throw e;
|
|
}
|
|
this.logger.clear();
|
|
}
|
|
|
|
private get verbosity() {
|
|
return this.options?.verbosity ?? Verbosity.silent;
|
|
}
|
|
|
|
isBuilt(): boolean {
|
|
return this._built;
|
|
}
|
|
|
|
protected rebuildServer() {
|
|
this.server = new Hono<ServerEnv>();
|
|
if (this.options?.basePath) {
|
|
this.server = this.server.basePath(this.options.basePath);
|
|
}
|
|
if (this.options?.onServerInit) {
|
|
this.options.onServerInit(this.server);
|
|
}
|
|
|
|
// optional method for each module to register global middlewares, etc.
|
|
objectEach(this.modules, (module) => {
|
|
module.onServerInit(this.server);
|
|
});
|
|
}
|
|
|
|
ctx(rebuild?: boolean): ModuleBuildContext {
|
|
if (rebuild) {
|
|
this.rebuildServer();
|
|
this.em = this.em
|
|
? this.em.clear()
|
|
: new EntityManager([], this.connection, [], [], this.emgr);
|
|
this.guard = new Guard();
|
|
this.mcp = new McpServer(undefined as any, {
|
|
app: new Proxy(this, {
|
|
get: () => {
|
|
throw new Error("app is not available in mcp context");
|
|
},
|
|
}) as any,
|
|
ctx: () => this.ctx(),
|
|
});
|
|
}
|
|
|
|
const ctx = {
|
|
connection: this.connection,
|
|
server: this.server,
|
|
em: this.em,
|
|
emgr: this.emgr,
|
|
guard: this.guard,
|
|
flags: Module.ctx_flags,
|
|
logger: this.logger,
|
|
mcp: this.mcp,
|
|
};
|
|
|
|
return {
|
|
...ctx,
|
|
helper: new ModuleHelper(ctx),
|
|
};
|
|
}
|
|
|
|
extractSecrets() {
|
|
const moduleConfigs = JSON.parse(JSON.stringify(this.configs()));
|
|
const secrets = { ...this.options?.secrets };
|
|
const extractedKeys: string[] = [];
|
|
|
|
for (const [key, module] of Object.entries(this.modules)) {
|
|
const config = moduleConfigs[key];
|
|
const schema = module.getSchema();
|
|
|
|
const extracted = [...schema.walk({ data: config })].filter(
|
|
(n) => n.schema instanceof SecretSchema,
|
|
);
|
|
|
|
for (const n of extracted) {
|
|
const path = [key, ...n.instancePath].join(".");
|
|
|
|
if (typeof n.data === "string") {
|
|
extractedKeys.push(path);
|
|
secrets[path] = n.data;
|
|
setPath(moduleConfigs, path, "");
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
configs: moduleConfigs,
|
|
secrets: pick(secrets, extractedKeys),
|
|
extractedKeys,
|
|
};
|
|
}
|
|
|
|
protected async setConfigs(configs: ModuleConfigs): Promise<void> {
|
|
this.logger.log("setting configs");
|
|
for await (const [key, config] of Object.entries(configs)) {
|
|
if (!(key in this.modules)) continue;
|
|
|
|
try {
|
|
// setting "noEmit" to true, to not force listeners to update
|
|
const result = await this.modules[key].schema().set(config as any, true);
|
|
} catch (e) {
|
|
console.error(e);
|
|
throw new Error(
|
|
`Failed to set config for module ${key}: ${JSON.stringify(config, null, 2)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async build(opts?: any) {
|
|
this.createModules(this.options?.initial ?? {});
|
|
await this.buildModules();
|
|
|
|
// if secrets were provided, extract, merge and build again
|
|
const provided_secrets = this.options?.secrets ?? {};
|
|
if (Object.keys(provided_secrets).length > 0) {
|
|
const { configs, extractedKeys } = this.extractSecrets();
|
|
|
|
for (const key of extractedKeys) {
|
|
if (key in provided_secrets) {
|
|
setPath(configs, key, provided_secrets[key]);
|
|
}
|
|
}
|
|
|
|
await this.setConfigs(configs);
|
|
await this.buildModules();
|
|
}
|
|
|
|
return this;
|
|
}
|
|
|
|
protected async buildModules(options?: {
|
|
graceful?: boolean;
|
|
ignoreFlags?: boolean;
|
|
drop?: boolean;
|
|
}) {
|
|
const state = {
|
|
built: false,
|
|
modules: [] as ModuleKey[],
|
|
synced: false,
|
|
saved: false,
|
|
reloaded: false,
|
|
};
|
|
|
|
this.logger.context("buildModules").log("triggered", options, this._built);
|
|
if (options?.graceful && this._built) {
|
|
this.logger.log("skipping build (graceful)");
|
|
return state;
|
|
}
|
|
|
|
this.logger.log("building");
|
|
const ctx = this.ctx(true);
|
|
for (const key in this.modules) {
|
|
await this.modules[key].setContext(ctx).build();
|
|
this.logger.log("built", key);
|
|
state.modules.push(key as ModuleKey);
|
|
}
|
|
|
|
this._built = state.built = true;
|
|
this.logger.log("modules built", ctx.flags);
|
|
|
|
if (this.options?.onModulesBuilt) {
|
|
await this.options.onModulesBuilt(ctx);
|
|
}
|
|
|
|
if (options?.ignoreFlags !== true) {
|
|
if (ctx.flags.sync_required) {
|
|
ctx.flags.sync_required = false;
|
|
this.logger.log("db sync requested");
|
|
|
|
// ignore sync request on code mode since system tables
|
|
// are probably never fully in provided config
|
|
}
|
|
|
|
if (ctx.flags.ctx_reload_required) {
|
|
ctx.flags.ctx_reload_required = false;
|
|
this.logger.log("ctx reload requested");
|
|
this.ctx(true);
|
|
state.reloaded = true;
|
|
}
|
|
}
|
|
|
|
// reset all falgs
|
|
this.logger.log("resetting flags");
|
|
ctx.flags = Module.ctx_flags;
|
|
|
|
// storing last stable config version
|
|
//this._stable_configs = $diff.clone(this.configs());
|
|
|
|
this.logger.clear();
|
|
return state;
|
|
}
|
|
|
|
get<K extends keyof Modules>(key: K): Modules[K] {
|
|
if (!(key in this.modules)) {
|
|
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
|
}
|
|
return this.modules[key];
|
|
}
|
|
|
|
version() {
|
|
return 0;
|
|
}
|
|
|
|
built() {
|
|
return this._built;
|
|
}
|
|
|
|
configs(): ModuleConfigs {
|
|
return transformObject(this.modules, (module) => module.toJSON(true)) as any;
|
|
}
|
|
|
|
getSchema() {
|
|
const schemas = transformObject(this.modules, (module) => module.getSchema());
|
|
|
|
return {
|
|
version: this.version(),
|
|
...schemas,
|
|
} as { version: number } & ModuleSchemas;
|
|
}
|
|
|
|
toJSON(secrets?: boolean): { version: number } & ModuleConfigs {
|
|
const modules = transformObject(this.modules, (module) => {
|
|
if (this._built) {
|
|
return module.isBuilt() ? module.toJSON(secrets) : module.configDefault;
|
|
}
|
|
|
|
// returns no config if the all modules are not built
|
|
return undefined;
|
|
});
|
|
|
|
return {
|
|
version: this.version(),
|
|
...modules,
|
|
} as any;
|
|
}
|
|
}
|
|
|
|
export function getDefaultSchema() {
|
|
const schema = {
|
|
type: "object",
|
|
...transformObject(MODULES, (module) => module.prototype.getSchema()),
|
|
};
|
|
|
|
return schema as any;
|
|
}
|
|
|
|
export function getDefaultConfig(): ModuleConfigs {
|
|
const config = transformObject(MODULES, (module) => {
|
|
return module.prototype.getSchema().template(
|
|
{},
|
|
{
|
|
withOptional: true,
|
|
withExtendedOptional: true,
|
|
},
|
|
);
|
|
});
|
|
|
|
return structuredClone(config) as any;
|
|
}
|