mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
refactor `code` and `hybrid` modes for better type safety and configuration flexibility. add `_isProd` helper to standardize environment checks and improve plugin syncing warnings. adjust validation logic for clean JSON schema handling and enhance test coverage for modes.
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");
|
|
|
|
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;
|
|
}
|