From 8b4b63b3cd4b5753d83da9905e6d64cc5b6713ad Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 12 Jun 2025 15:29:53 +0200 Subject: [PATCH 1/5] feat: improved abilities of plugins, moved schema fns to ctx --- app/__test__/App.spec.ts | 96 +++++++++++++++-- app/__test__/app/App.spec.ts | 1 + app/__test__/modules/Module.spec.ts | 27 +++-- app/__test__/modules/module-test-suite.ts | 7 +- app/src/App.ts | 77 +++++++++++--- app/src/auth/AppAuth.ts | 6 +- app/src/auth/api/AuthController.ts | 2 +- app/src/core/index.ts | 11 +- app/src/core/types.ts | 2 + app/src/data/api/DataController.ts | 2 +- app/src/data/relations/RelationAccessor.ts | 9 ++ app/src/index.ts | 2 + app/src/media/AppMedia.ts | 2 +- app/src/media/api/MediaController.ts | 2 +- app/src/modules/Module.ts | 90 +--------------- app/src/modules/ModuleHelper.ts | 113 +++++++++++++++++++++ app/src/modules/ModuleManager.ts | 14 ++- 17 files changed, 330 insertions(+), 133 deletions(-) create mode 100644 app/src/modules/ModuleHelper.ts diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 79fdc51..93c4f6f 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -1,6 +1,9 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { App } from "../src"; import { getDummyConnection } from "./helper"; +import { Hono } from "hono"; +import * as proto from "../src/data/prototype"; +import { pick } from "lodash-es"; const { dummyConnection, afterAllCleanup } = getDummyConnection(); afterEach(afterAllCleanup); @@ -10,18 +13,91 @@ describe("App tests", async () => { const app = new App(dummyConnection); await app.build(); - //expect(await app.data?.em.ping()).toBeTrue(); + expect(await app.em.ping()).toBeTrue(); }); - /*test.only("what", async () => { - const app = new App(dummyConnection, { - auth: { - enabled: true, + test("plugins", async () => { + const called: string[] = []; + const app = App.create({ + initialConfig: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [ + (app) => { + expect(app).toBeDefined(); + expect(app).toBeInstanceOf(App); + return { + name: "test", + schema: () => { + called.push("schema"); + return proto.em( + { + posts: proto.entity("posts", { + title: proto.text(), + }), + comments: proto.entity("comments", { + content: proto.text(), + }), + users: proto.entity("users", { + email_verified: proto.boolean(), + }), + }, + (fn, s) => { + fn.relation(s.comments).manyToOne(s.posts); + fn.index(s.posts).on(["title"]); + }, + ); + }, + beforeBuild: async () => { + called.push("beforeBuild"); + }, + onBuilt: async () => { + called.push("onBuilt"); + }, + onServerInit: async (server) => { + called.push("onServerInit"); + expect(server).toBeDefined(); + expect(server).toBeInstanceOf(Hono); + }, + onFirstBoot: async () => { + called.push("onFirstBoot"); + }, + }; + }, + ], }, }); - await app.module.auth.build(); - await app.module.data.build(); - console.log(app.em.entities.map((e) => e.name)); - console.log(await app.em.schema().getDiff()); - });*/ + + await app.build(); + + expect(app.em.entities.map((e) => e.name)).toEqual(["users", "posts", "comments"]); + expect(app.em.indices.map((i) => i.name)).toEqual([ + "idx_unique_users_email", + "idx_users_strategy", + "idx_users_strategy_value", + "idx_posts_title", + ]); + expect( + app.em.relations.all.map((r) => pick(r.toJSON(), ["type", "source", "target"])), + ).toEqual([ + { + type: "n:1", + source: "comments", + target: "posts", + }, + ]); + expect(called).toEqual([ + "onServerInit", + "beforeBuild", + "onServerInit", + "schema", + "onFirstBoot", + "onBuilt", + ]); + expect(app.plugins).toHaveLength(1); + expect(app.plugins.map((p) => p.name)).toEqual(["test"]); + }); }); diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index 860258a..6b7aebb 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -20,6 +20,7 @@ describe("App", () => { "guard", "flags", "logger", + "helper", ]); }, }, diff --git a/app/__test__/modules/Module.spec.ts b/app/__test__/modules/Module.spec.ts index 8cca811..380591d 100644 --- a/app/__test__/modules/Module.spec.ts +++ b/app/__test__/modules/Module.spec.ts @@ -4,6 +4,7 @@ import { type TSchema, Type } from "@sinclair/typebox"; import { EntityManager, em, entity, index, text } from "../../src/data"; import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { Module } from "../../src/modules/Module"; +import { ModuleHelper } from "modules/ModuleHelper"; function createModule(schema: Schema) { class TestModule extends Module { @@ -46,9 +47,9 @@ describe("Module", async () => { } prt = { - ensureEntity: this.ensureEntity.bind(this), - ensureIndex: this.ensureIndex.bind(this), - ensureSchema: this.ensureSchema.bind(this), + ensureEntity: this.ctx.helper.ensureEntity.bind(this.ctx.helper), + ensureIndex: this.ctx.helper.ensureIndex.bind(this.ctx.helper), + ensureSchema: this.ctx.helper.ensureSchema.bind(this.ctx.helper), }; get em() { @@ -63,7 +64,11 @@ describe("Module", async () => { _em.relations, _em.indices, ); - return new M({} as any, { em, flags: Module.ctx_flags } as any); + const ctx = { + em, + flags: Module.ctx_flags, + }; + return new M({} as any, { ...ctx, helper: new ModuleHelper(ctx as any) } as any); } function flat(_em: EntityManager) { return { @@ -143,14 +148,9 @@ describe("Module", async () => { // this should only add the field "important" m.prt.ensureEntity( - entity( - "u", - { - important: text(), - }, - undefined, - "system", - ), + entity("u", { + important: text(), + }), ); expect(m.ctx.flags.sync_required).toBe(true); @@ -159,8 +159,7 @@ describe("Module", async () => { { name: "u", fields: ["id", "name", "important"], - // ensured type must be present - type: "system", + type: "regular", }, { name: "p", diff --git a/app/__test__/modules/module-test-suite.ts b/app/__test__/modules/module-test-suite.ts index 4ad7e5d..610dc28 100644 --- a/app/__test__/modules/module-test-suite.ts +++ b/app/__test__/modules/module-test-suite.ts @@ -8,10 +8,11 @@ import { Default, stripMark } from "../../src/core/utils"; import { EntityManager } from "../../src/data"; import { Module, type ModuleBuildContext } from "../../src/modules/Module"; import { getDummyConnection } from "../helper"; +import { ModuleHelper } from "modules/ModuleHelper"; export function makeCtx(overrides?: Partial): ModuleBuildContext { const { dummyConnection } = getDummyConnection(); - return { + const ctx = { connection: dummyConnection, server: new Hono(), em: new EntityManager([], dummyConnection), @@ -21,6 +22,10 @@ export function makeCtx(overrides?: Partial): ModuleBuildCon logger: new DebugLogger(false), ...overrides, }; + return { + ...ctx, + helper: new ModuleHelper(ctx as any), + } as any; } export function moduleTestSuite(module: { new (): Module }) { diff --git a/app/src/App.ts b/app/src/App.ts index 956229c..ea72feb 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,7 +1,7 @@ import type { CreateUserPayload } from "auth/AppAuth"; import { $console } from "core"; import { Event } from "core/events"; -import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; +import { Connection, type LibSqlCredentials, LibsqlConnection, type em as prototypeEm } from "data"; import type { Hono } from "hono"; import { ModuleManager, @@ -14,12 +14,21 @@ import { import * as SystemPermissions from "modules/permissions"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { SystemController } from "modules/server/SystemController"; +import type { MaybePromise } from "core/types"; +import type { ServerEnv } from "modules/Controller"; // biome-ignore format: must be here import { Api, type ApiOptions } from "Api"; -import type { ServerEnv } from "modules/Controller"; -export type AppPlugin = (app: App) => Promise | void; +export type AppPluginConfig = { + name: string; + schema?: () => MaybePromise | void>; + beforeBuild?: () => MaybePromise; + onBuilt?: () => MaybePromise; + onServerInit?: (server: Hono) => MaybePromise; + onFirstBoot?: () => MaybePromise; +}; +export type AppPlugin = (app: App) => AppPluginConfig; abstract class AppEvent extends Event<{ app: App } & A> {} export class AppConfigUpdatedEvent extends AppEvent { @@ -73,9 +82,9 @@ export class App { modules: ModuleManager; adminController?: AdminController; _id: string = crypto.randomUUID(); + plugins: AppPluginConfig[]; private trigger_first_boot = false; - private plugins: AppPlugin[]; private _building: boolean = false; constructor( @@ -83,13 +92,14 @@ export class App { _initialConfig?: InitialModuleConfigs, private options?: AppOptions, ) { - this.plugins = options?.plugins ?? []; + this.plugins = (options?.plugins ?? []).map((plugin) => plugin(this)); this.modules = new ModuleManager(connection, { ...(options?.manager ?? {}), initial: _initialConfig, onUpdated: this.onUpdated.bind(this), onFirstBoot: this.onFirstBoot.bind(this), onServerInit: this.onServerInit.bind(this), + onModulesBuilt: this.onModulesBuilt.bind(this), }); this.modules.ctx().emgr.registerEvents(AppEvents); } @@ -98,6 +108,32 @@ export class App { return this.modules.ctx().emgr; } + protected async runPlugins( + key: Key, + ...args: any[] + ): Promise<{ name: string; result: any }[]> { + const results: { name: string; result: any }[] = []; + for (const plugin of this.plugins) { + try { + if (key in plugin && plugin[key]) { + const fn = plugin[key]; + if (fn && typeof fn === "function") { + $console.debug(`[Plugin:${plugin.name}] ${key}`); + // @ts-expect-error + const result = await fn(...args); + results.push({ + name: plugin.name, + result, + }); + } + } + } catch (e) { + $console.warn(`[Plugin:${plugin.name}] error running "${key}"`, String(e)); + } + } + return results as any; + } + async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) { // prevent multiple concurrent builds if (this._building) { @@ -106,6 +142,8 @@ export class App { } if (!options?.forceBuild) return; } + + await this.runPlugins("beforeBuild"); this._building = true; if (options?.sync) this.modules.ctx().flags.sync_required = true; @@ -117,13 +155,10 @@ export class App { guard.registerPermissions(Object.values(SystemPermissions)); server.route("/api/system", new SystemController(this).getController()); - // load plugins - if (this.plugins.length > 0) { - await Promise.all(this.plugins.map((plugin) => plugin(this))); - } - + // emit built event $console.log("App built"); await this.emgr.emit(new AppBuiltEvent({ app: this })); + await this.runPlugins("onBuilt"); // first boot is set from ModuleManager when there wasn't a config table if (this.trigger_first_boot) { @@ -223,12 +258,13 @@ export class App { await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); } - async onFirstBoot() { + protected async onFirstBoot() { $console.log("App first boot"); this.trigger_first_boot = true; + await this.runPlugins("onFirstBoot"); } - async onServerInit(server: Hono) { + protected async onServerInit(server: Hono) { server.use(async (c, next) => { c.set("app", this); await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw })); @@ -258,6 +294,23 @@ export class App { if (this.options?.manager?.onServerInit) { this.options.manager.onServerInit(server); } + + await this.runPlugins("onServerInit", server); + } + + protected async onModulesBuilt(ctx: ModuleBuildContext) { + const results = (await this.runPlugins("schema")) as { + name: string; + result: ReturnType; + }[]; + if (results.length > 0) { + for (const { name, result } of results) { + if (result) { + $console.log(`[Plugin:${name}] schema`); + ctx.helper.ensureSchema(result); + } + } + } } } diff --git a/app/src/auth/AppAuth.ts b/app/src/auth/AppAuth.ts index 898c9f2..973332e 100644 --- a/app/src/auth/AppAuth.ts +++ b/app/src/auth/AppAuth.ts @@ -140,7 +140,7 @@ export class AppAuth extends Module { registerEntities() { const users = this.getUsersEntity(true); - this.ensureSchema( + this.ctx.helper.ensureSchema( em( { [users.name as "users"]: users, @@ -153,13 +153,13 @@ export class AppAuth extends Module { try { const roles = Object.keys(this.config.roles ?? {}); - this.replaceEntityField(users, "role", enumm({ enum: roles })); + this.ctx.helper.replaceEntityField(users, "role", enumm({ enum: roles })); } catch (e) {} try { // also keep disabled strategies as a choice const strategies = Object.keys(this.config.strategies ?? {}); - this.replaceEntityField(users, "strategy", enumm({ enum: strategies })); + this.ctx.helper.replaceEntityField(users, "strategy", enumm({ enum: strategies })); } catch (e) {} } diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 1af3956..1f2b85d 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -184,6 +184,6 @@ export class AuthController extends Controller { this.registerStrategyActions(strategy, hono); } - return hono.all("*", (c) => c.notFound()); + return hono; } } diff --git a/app/src/core/index.ts b/app/src/core/index.ts index 9ff5370..ae33dd6 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -26,7 +26,16 @@ export { } from "./object/query/query"; export { Registry, type Constructor } from "./registry/Registry"; export { getFlashMessage } from "./server/flash"; -export { s, jsc, describeRoute } from "./object/schema"; +export { + s, + parse, + jsc, + describeRoute, + schemaToSpec, + openAPISpecs, + type ParseOptions, + InvalidSchemaError, +} from "./object/schema"; export * from "./console"; export * from "./events"; diff --git a/app/src/core/types.ts b/app/src/core/types.ts index cfb32e4..1751766 100644 --- a/app/src/core/types.ts +++ b/app/src/core/types.ts @@ -2,3 +2,5 @@ export interface Serializable { toJSON(): Json; fromJSON(json: Json): Class; } + +export type MaybePromise = T | Promise; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index bc86a8d..086274f 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -225,7 +225,7 @@ export class DataController extends Controller { }, ); - return hono.all("*", (c) => c.notFound()); + return hono; } private getEntityRoutes() { diff --git a/app/src/data/relations/RelationAccessor.ts b/app/src/data/relations/RelationAccessor.ts index dbd51e7..4c474b5 100644 --- a/app/src/data/relations/RelationAccessor.ts +++ b/app/src/data/relations/RelationAccessor.ts @@ -12,6 +12,15 @@ export class RelationAccessor { return this._relations; } + exists(relation: EntityRelation): boolean { + return this._relations.some( + (r) => + r.source.entity.name === relation.source.entity.name && + r.target.entity.name === relation.target.entity.name && + r.type === relation.type, + ); + } + /** * Searches for the relations of [entity_name] */ diff --git a/app/src/index.ts b/app/src/index.ts index b47e565..8e75d16 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -18,6 +18,8 @@ export { type InitialModuleConfigs, } from "./modules/ModuleManager"; +export type { ServerEnv } from "modules/Controller"; + export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index 51c7586..18b536e 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -51,7 +51,7 @@ export class AppMedia extends Module { this.ctx.server.route(this.basepath, new MediaController(this).getController()); const media = this.getMediaEntity(true); - this.ensureSchema( + this.ctx.helper.ensureSchema( em({ [media.name as "media"]: media }, ({ index }, { media }) => { index(media).on(["path"], true).on(["reference"]).on(["entity_id"]); }), diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index cdcbe5a..6621574 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -297,6 +297,6 @@ export class MediaController extends Controller { }, ); - return hono.all("*", (c) => c.notFound()); + return hono; } } diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts index 249fe08..d416497 100644 --- a/app/src/modules/Module.ts +++ b/app/src/modules/Module.ts @@ -2,19 +2,10 @@ import type { Guard } from "auth"; import { type DebugLogger, SchemaObject } from "core"; import type { EventManager } from "core/events"; import type { Static, TSchema } from "core/utils"; -import { - type Connection, - type EntityIndex, - type EntityManager, - type Field, - FieldPrototype, - make, - type em as prototypeEm, -} from "data"; -import { Entity } from "data"; +import type { Connection, EntityManager } from "data"; import type { Hono } from "hono"; -import { isEqual } from "lodash-es"; import type { ServerEnv } from "modules/Controller"; +import type { ModuleHelper } from "./ModuleHelper"; export type ModuleBuildContext = { connection: Connection; @@ -24,6 +15,7 @@ export type ModuleBuildContext = { guard: Guard; logger: DebugLogger; flags: (typeof Module)["ctx_flags"]; + helper: ModuleHelper; }; export abstract class Module> { @@ -141,80 +133,4 @@ export abstract class Module> { return this.config; } - - protected ensureEntity(entity: Entity) { - const instance = this.ctx.em.entity(entity.name, true); - - // check fields - if (!instance) { - this.ctx.em.addEntity(entity); - this.ctx.flags.sync_required = true; - return; - } - - // if exists, check all fields required are there - // @todo: check if the field also equal - for (const field of entity.fields) { - const instanceField = instance.field(field.name); - if (!instanceField) { - instance.addField(field); - this.ctx.flags.sync_required = true; - } else { - const changes = this.setEntityFieldConfigs(field, instanceField); - if (changes > 0) { - this.ctx.flags.sync_required = true; - } - } - } - - // replace entity (mainly to keep the ensured type) - this.ctx.em.__replaceEntity( - new Entity(instance.name, instance.fields, instance.config, entity.type), - ); - } - - protected ensureIndex(index: EntityIndex) { - if (!this.ctx.em.hasIndex(index)) { - this.ctx.em.addIndex(index); - this.ctx.flags.sync_required = true; - } - } - - protected ensureSchema>(schema: Schema): Schema { - Object.values(schema.entities ?? {}).forEach(this.ensureEntity.bind(this)); - schema.indices?.forEach(this.ensureIndex.bind(this)); - - return schema; - } - - protected setEntityFieldConfigs( - parent: Field, - child: Field, - props: string[] = ["hidden", "fillable", "required"], - ) { - let changes = 0; - for (const prop of props) { - if (!isEqual(child.config[prop], parent.config[prop])) { - child.config[prop] = parent.config[prop]; - changes++; - } - } - return changes; - } - - protected replaceEntityField( - _entity: string | Entity, - field: Field | string, - _newField: Field | FieldPrototype, - ) { - const entity = this.ctx.em.entity(_entity); - const name = typeof field === "string" ? field : field.name; - const newField = - _newField instanceof FieldPrototype ? make(name, _newField as any) : _newField; - - // ensure keeping vital config - this.setEntityFieldConfigs(entity.field(name)!, newField); - - entity.__replaceField(name, newField); - } } diff --git a/app/src/modules/ModuleHelper.ts b/app/src/modules/ModuleHelper.ts new file mode 100644 index 0000000..5088510 --- /dev/null +++ b/app/src/modules/ModuleHelper.ts @@ -0,0 +1,113 @@ +import { + type EntityIndex, + type EntityRelation, + type Field, + type em as prototypeEm, + FieldPrototype, + make, + Entity, + entityTypes, +} from "data"; +import { isEqual } from "lodash-es"; +import type { ModuleBuildContext } from "./Module"; + +export class ModuleHelper { + constructor(protected ctx: Omit) {} + + get em() { + return this.ctx.em; + } + + get flags() { + return this.ctx.flags; + } + + ensureEntity(entity: Entity) { + const instance = this.em.entity(entity.name, true); + + // check fields + if (!instance) { + this.em.addEntity(entity); + this.flags.sync_required = true; + return; + } + + // if exists, check all fields required are there + // @todo: potentially identify system and generated entities and take that as instance + // @todo: check if the field also equal + for (const field of entity.fields) { + const instanceField = instance.field(field.name); + if (!instanceField) { + instance.addField(field); + this.flags.sync_required = true; + } else { + const changes = this.setEntityFieldConfigs(field, instanceField); + if (changes > 0) { + this.flags.sync_required = true; + } + } + } + + // if type is different, keep the highest + if (instance.type !== entity.type) { + const instance_i = entityTypes.indexOf(instance.type); + const entity_i = entityTypes.indexOf(entity.type); + const type = entity_i > instance_i ? entity.type : instance.type; + + this.em.__replaceEntity(new Entity(instance.name, instance.fields, instance.config, type)); + } + } + + ensureIndex(index: EntityIndex) { + if (!this.em.hasIndex(index)) { + this.em.addIndex(index); + this.flags.sync_required = true; + } + } + + ensureRelation(relation: EntityRelation) { + if (!this.em.relations.exists(relation)) { + this.em.addRelation(relation); + this.flags.sync_required = true; + } + } + + ensureSchema>(schema: Schema): Schema { + Object.values(schema.entities ?? {}).forEach(this.ensureEntity.bind(this)); + schema.indices?.forEach(this.ensureIndex.bind(this)); + schema.relations?.forEach(this.ensureRelation.bind(this)); + + return schema; + } + + setEntityFieldConfigs( + parent: Field, + child: Field, + props: string[] = ["hidden", "fillable", "required"], + ) { + let changes = 0; + for (const prop of props) { + if (!isEqual(child.config[prop], parent.config[prop])) { + child.config[prop] = parent.config[prop]; + changes++; + } + } + return changes; + } + + replaceEntityField( + _entity: string | Entity, + field: Field | string, + _newField: Field | FieldPrototype, + ) { + const entity = this.em.entity(_entity); + const name = typeof field === "string" ? field : field.name; + const newField = + _newField instanceof FieldPrototype ? make(name, _newField as any) : _newField; + + // ensure keeping vital config + this.setEntityFieldConfigs(entity.field(name)!, newField); + + entity.__replaceField(name, newField); + } +} diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 022e314..86030df 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -34,6 +34,7 @@ import { AppMedia } from "../media/AppMedia"; import type { ServerEnv } from "./Controller"; import { Module, type ModuleBuildContext } from "./Module"; import * as tbbox from "@sinclair/typebox"; +import { ModuleHelper } from "./ModuleHelper"; const { Type } = tbbox; export type { ModuleBuildContext }; @@ -92,6 +93,8 @@ export type ModuleManagerOptions = { trustFetched?: boolean; // runs when initial config provided on a fresh database seed?: (ctx: ModuleBuildContext) => Promise; + // called right after modules are built, before finish + onModulesBuilt?: (ctx: ModuleBuildContext) => Promise; /** @deprecated */ verbosity?: Verbosity; }; @@ -267,7 +270,7 @@ export class ModuleManager { this.guard = new Guard(); } - return { + const ctx = { connection: this.connection, server: this.server, em: this.em, @@ -276,6 +279,11 @@ export class ModuleManager { flags: Module.ctx_flags, logger: this.logger, }; + + return { + ...ctx, + helper: new ModuleHelper(ctx), + }; } private async fetch(): Promise { @@ -549,6 +557,10 @@ export class ModuleManager { 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; From fe5ccd420608f1fab6c85ed7b0458a2eae0cc6a7 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 12 Jun 2025 17:00:06 +0200 Subject: [PATCH 2/5] refactor and move cloudflare image transformation plugin --- app/src/adapter/cloudflare/index.ts | 4 + .../plugins/image-optimization.plugin.ts | 86 +++++++++++++++++++ .../cloudflare/image-optimization-plugin.ts | 83 ------------------ 3 files changed, 90 insertions(+), 83 deletions(-) create mode 100644 app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts delete mode 100644 app/src/plugins/cloudflare/image-optimization-plugin.ts diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index 60e6a77..57daed1 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,4 +1,5 @@ import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection"; +import { ImageOptimizationPlugin } from "./plugins/image-optimization.plugin"; export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; @@ -13,6 +14,9 @@ export { type BindingMap, } from "./bindings"; export { constants } from "./config"; +export const plugins = { + imageOptimization: ImageOptimizationPlugin, +}; export function d1(config: D1ConnectionConfig) { return new D1Connection(config); diff --git a/app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts b/app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts new file mode 100644 index 0000000..db9130a --- /dev/null +++ b/app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts @@ -0,0 +1,86 @@ +import type { App, AppPlugin } from "bknd"; + +export type ImageOptimizationPluginOptions = { + accessUrl?: string; + resolvePath?: string; + autoFormat?: boolean; + devBypass?: string; +}; + +export function ImageOptimizationPlugin({ + accessUrl = "/_plugin/image/optimize", + resolvePath = "/api/media/file", + autoFormat = true, + devBypass, +}: ImageOptimizationPluginOptions = {}): AppPlugin { + const disallowedAccessUrls = ["/api", "/admin", "/_optimize"]; + if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) { + throw new Error(`Disallowed accessUrl: ${accessUrl}`); + } + + return (app: App) => ({ + name: "cf-image-optimization", + onBuilt: () => { + app.server.get(`${accessUrl}/:path{.+$}`, async (c) => { + const request = c.req.raw; + const url = new URL(request.url); + + if (devBypass) { + return c.redirect(devBypass + url.pathname + url.search, 302); + } + + const storage = app.module.media?.storage; + if (!storage) { + throw new Error("No media storage configured"); + } + + const path = c.req.param("path"); + if (!path) { + throw new Error("No url provided"); + } + + const imageURL = `${url.origin}${resolvePath}/${path}`; + const metadata = await storage.objectMetadata(path); + + // Cloudflare-specific options are in the cf object. + const params = Object.fromEntries(url.searchParams.entries()); + const options: RequestInitCfPropertiesImage = {}; + + // Copy parameters from query string to request options. + // You can implement various different parameters here. + if ("fit" in params) options.fit = params.fit as any; + if ("width" in params) options.width = Number.parseInt(params.width); + if ("height" in params) options.height = Number.parseInt(params.height); + if ("quality" in params) options.quality = Number.parseInt(params.quality); + + // Your Worker is responsible for automatic format negotiation. Check the Accept header. + if (autoFormat) { + const accept = request.headers.get("Accept")!; + if (/image\/avif/.test(accept)) { + options.format = "avif"; + } else if (/image\/webp/.test(accept)) { + options.format = "webp"; + } + } + + // Build a request that passes through request headers + const imageRequest = new Request(imageURL, { + headers: request.headers, + }); + + // Returning fetch() with resizing options will pass through response with the resized image. + const res = await fetch(imageRequest, { cf: { image: options } }); + + return new Response(res.body, { + status: res.status, + statusText: res.statusText, + headers: { + "Cache-Control": "public, max-age=600", + "Content-Type": metadata.type, + "Content-Length": metadata.size.toString(), + }, + }); + }); + }, + }); +} diff --git a/app/src/plugins/cloudflare/image-optimization-plugin.ts b/app/src/plugins/cloudflare/image-optimization-plugin.ts deleted file mode 100644 index d611279..0000000 --- a/app/src/plugins/cloudflare/image-optimization-plugin.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { App } from "../../App"; - -export type ImageOptimizationPluginOptions = { - accessUrl?: string; - resolvePath?: string; - autoFormat?: boolean; - devBypass?: string; -}; - -export function ImageOptimizationPlugin({ - accessUrl = "/_plugin/image/optimize", - resolvePath = "/api/media/file", - autoFormat = true, - devBypass, -}: ImageOptimizationPluginOptions = {}) { - const disallowedAccessUrls = ["/api", "/admin", "/_optimize"]; - if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) { - throw new Error(`Disallowed accessUrl: ${accessUrl}`); - } - - return (app: App) => { - app.module.server.client.get(`${accessUrl}/:path{.+$}`, async (c) => { - const request = c.req.raw; - const url = new URL(request.url); - - if (devBypass) { - return c.redirect(devBypass + url.pathname + url.search, 302); - } - - const storage = app.module.media?.storage; - if (!storage) { - throw new Error("No media storage configured"); - } - - const path = c.req.param("path"); - if (!path) { - throw new Error("No url provided"); - } - - const imageURL = `${url.origin}${resolvePath}/${path}`; - const metadata = await storage.objectMetadata(path); - - // Cloudflare-specific options are in the cf object. - const params = Object.fromEntries(url.searchParams.entries()); - const options: RequestInitCfPropertiesImage = {}; - - // Copy parameters from query string to request options. - // You can implement various different parameters here. - if ("fit" in params) options.fit = params.fit as any; - if ("width" in params) options.width = Number.parseInt(params.width); - if ("height" in params) options.height = Number.parseInt(params.height); - if ("quality" in params) options.quality = Number.parseInt(params.quality); - - // Your Worker is responsible for automatic format negotiation. Check the Accept header. - if (autoFormat) { - const accept = request.headers.get("Accept")!; - if (/image\/avif/.test(accept)) { - options.format = "avif"; - } else if (/image\/webp/.test(accept)) { - options.format = "webp"; - } - } - - // Build a request that passes through request headers - const imageRequest = new Request(imageURL, { - headers: request.headers, - }); - - // Returning fetch() with resizing options will pass through response with the resized image. - const res = await fetch(imageRequest, { cf: { image: options } }); - - return new Response(res.body, { - status: res.status, - statusText: res.statusText, - headers: { - "Cache-Control": "public, max-age=600", - "Content-Type": metadata.type, - "Content-Length": metadata.size.toString(), - }, - }); - }); - }; -} From 8517c9b90ba1f27cfbff590d93bb212d27266195 Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 12 Jun 2025 19:58:18 +0200 Subject: [PATCH 3/5] added a few initial plugins --- app/__test__/App.spec.ts | 4 +++ app/build.ts | 1 + app/package.json | 22 ++++++++++++ app/src/App.ts | 2 ++ app/src/adapter/cloudflare/index.ts | 4 --- app/src/data/entities/EntityTypescript.ts | 2 +- app/src/modules/server/SystemController.ts | 1 + .../cloudflare}/image-optimization.plugin.ts | 6 ++-- app/src/plugins/dev/show-routes.plugin.ts | 18 ++++++++++ app/src/plugins/dev/sync-config.plugin.ts | 35 +++++++++++++++++++ app/src/plugins/dev/sync-types.plugin.ts | 31 ++++++++++++++++ app/src/plugins/index.ts | 7 ++++ 12 files changed, 125 insertions(+), 8 deletions(-) rename app/src/{adapter/cloudflare/plugins => plugins/cloudflare}/image-optimization.plugin.ts (95%) create mode 100644 app/src/plugins/dev/show-routes.plugin.ts create mode 100644 app/src/plugins/dev/sync-config.plugin.ts create mode 100644 app/src/plugins/dev/sync-types.plugin.ts create mode 100644 app/src/plugins/index.ts diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 93c4f6f..2eb070f 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -51,6 +51,9 @@ describe("App tests", async () => { }, ); }, + onBoot: async () => { + called.push("onBoot"); + }, beforeBuild: async () => { called.push("beforeBuild"); }, @@ -90,6 +93,7 @@ describe("App tests", async () => { }, ]); expect(called).toEqual([ + "onBoot", "onServerInit", "beforeBuild", "onServerInit", diff --git a/app/build.ts b/app/build.ts index 698778b..44f704a 100644 --- a/app/build.ts +++ b/app/build.ts @@ -78,6 +78,7 @@ async function buildApi() { "src/core/utils/index.ts", "src/data/index.ts", "src/media/index.ts", + "src/plugins/index.ts", ], outDir: "dist", external: [...external], diff --git a/app/package.json b/app/package.json index 9ca0d89..a7e9050 100644 --- a/app/package.json +++ b/app/package.json @@ -183,6 +183,11 @@ "import": "./dist/media/index.js", "require": "./dist/media/index.js" }, + "./plugins": { + "types": "./dist/types/plugins/index.d.ts", + "import": "./dist/plugins/index.js", + "require": "./dist/plugins/index.js" + }, "./adapter/cloudflare": { "types": "./dist/types/adapter/cloudflare/index.d.ts", "import": "./dist/adapter/cloudflare/index.js", @@ -231,6 +236,23 @@ "./dist/styles.css": "./dist/ui/styles.css", "./dist/manifest.json": "./dist/static/.vite/manifest.json" }, + "typesVersions": { + "*": { + "data": ["./dist/types/data/index.d.ts"], + "core": ["./dist/types/core/index.d.ts"], + "utils": ["./dist/types/core/utils/index.d.ts"], + "cli": ["./dist/types/cli/index.d.ts"], + "media": ["./dist/types/media/index.d.ts"], + "plugins": ["./dist/types/plugins/index.d.ts"], + "adapter": ["./dist/types/adapter/index.d.ts"], + "adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"], + "adapter/vite": ["./dist/types/adapter/vite/index.d.ts"], + "adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"], + "adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"], + "adapter/bun": ["./dist/types/adapter/bun/index.d.ts"], + "adapter/node": ["./dist/types/adapter/node/index.d.ts"] + } + }, "publishConfig": { "access": "public" }, diff --git a/app/src/App.ts b/app/src/App.ts index ea72feb..628bfc3 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -27,6 +27,7 @@ export type AppPluginConfig = { onBuilt?: () => MaybePromise; onServerInit?: (server: Hono) => MaybePromise; onFirstBoot?: () => MaybePromise; + onBoot?: () => MaybePromise; }; export type AppPlugin = (app: App) => AppPluginConfig; @@ -93,6 +94,7 @@ export class App { private options?: AppOptions, ) { this.plugins = (options?.plugins ?? []).map((plugin) => plugin(this)); + this.runPlugins("onBoot"); this.modules = new ModuleManager(connection, { ...(options?.manager ?? {}), initial: _initialConfig, diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index 57daed1..60e6a77 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,5 +1,4 @@ import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection"; -import { ImageOptimizationPlugin } from "./plugins/image-optimization.plugin"; export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; @@ -14,9 +13,6 @@ export { type BindingMap, } from "./bindings"; export { constants } from "./config"; -export const plugins = { - imageOptimization: ImageOptimizationPlugin, -}; export function d1(config: D1ConnectionConfig) { return new D1Connection(config); diff --git a/app/src/data/entities/EntityTypescript.ts b/app/src/data/entities/EntityTypescript.ts index 26c1df9..b0aa89e 100644 --- a/app/src/data/entities/EntityTypescript.ts +++ b/app/src/data/entities/EntityTypescript.ts @@ -56,7 +56,7 @@ export class EntityTypescript { return this.em.entities.map((e) => e.toTypes()); } - protected getTab(count = 1) { + getTab(count = 1) { return this.options.indentChar.repeat(this.options.indentWidth).repeat(count); } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index f457e47..4793381 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -317,6 +317,7 @@ export class SystemController extends Controller { local: datetimeStringLocal(), utc: datetimeStringUTC(), }, + plugins: this.app.plugins.map((p) => p.name), }), ); diff --git a/app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts b/app/src/plugins/cloudflare/image-optimization.plugin.ts similarity index 95% rename from app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts rename to app/src/plugins/cloudflare/image-optimization.plugin.ts index db9130a..f023977 100644 --- a/app/src/adapter/cloudflare/plugins/image-optimization.plugin.ts +++ b/app/src/plugins/cloudflare/image-optimization.plugin.ts @@ -1,18 +1,18 @@ import type { App, AppPlugin } from "bknd"; -export type ImageOptimizationPluginOptions = { +export type CloudflareImageOptimizationOptions = { accessUrl?: string; resolvePath?: string; autoFormat?: boolean; devBypass?: string; }; -export function ImageOptimizationPlugin({ +export function cloudflareImageOptimization({ accessUrl = "/_plugin/image/optimize", resolvePath = "/api/media/file", autoFormat = true, devBypass, -}: ImageOptimizationPluginOptions = {}): AppPlugin { +}: CloudflareImageOptimizationOptions = {}): AppPlugin { const disallowedAccessUrls = ["/api", "/admin", "/_optimize"]; if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) { throw new Error(`Disallowed accessUrl: ${accessUrl}`); diff --git a/app/src/plugins/dev/show-routes.plugin.ts b/app/src/plugins/dev/show-routes.plugin.ts new file mode 100644 index 0000000..dcb75bf --- /dev/null +++ b/app/src/plugins/dev/show-routes.plugin.ts @@ -0,0 +1,18 @@ +import type { App, AppPlugin } from "bknd"; +import { showRoutes as showRoutesHono } from "hono/dev"; + +export type ShowRoutesOptions = { + once?: boolean; +}; + +export function showRoutes({ once = false }: ShowRoutesOptions = {}): AppPlugin { + let shown = false; + return (app: App) => ({ + name: "bknd-show-routes", + onBuilt: () => { + if (once && shown) return; + shown = true; + showRoutesHono(app.server); + }, + }); +} diff --git a/app/src/plugins/dev/sync-config.plugin.ts b/app/src/plugins/dev/sync-config.plugin.ts new file mode 100644 index 0000000..24d84d3 --- /dev/null +++ b/app/src/plugins/dev/sync-config.plugin.ts @@ -0,0 +1,35 @@ +import { App, type AppConfig, type AppPlugin } from "bknd"; + +export type SyncConfigOptions = { + enabled?: boolean; + includeSecrets?: boolean; + write: (config: AppConfig) => Promise; +}; + +export function syncConfig({ + enabled = true, + includeSecrets = false, + write, +}: SyncConfigOptions): AppPlugin { + let firstBoot = true; + return (app: App) => ({ + name: "bknd-sync-config", + onBuilt: async () => { + if (!enabled) return; + app.emgr.onEvent( + App.Events.AppConfigUpdatedEvent, + async () => { + await write?.(app.toJSON(includeSecrets)); + }, + { + id: "sync-config", + }, + ); + + if (firstBoot) { + firstBoot = false; + await write?.(app.toJSON(true)); + } + }, + }); +} diff --git a/app/src/plugins/dev/sync-types.plugin.ts b/app/src/plugins/dev/sync-types.plugin.ts new file mode 100644 index 0000000..484f8b6 --- /dev/null +++ b/app/src/plugins/dev/sync-types.plugin.ts @@ -0,0 +1,31 @@ +import { App, type AppPlugin } from "bknd"; +import { EntityTypescript } from "data/entities/EntityTypescript"; + +export type SyncTypesOptions = { + enabled?: boolean; + write: (et: EntityTypescript) => Promise; +}; + +export function syncTypes({ enabled = true, write }: SyncTypesOptions): AppPlugin { + let firstBoot = true; + return (app: App) => ({ + name: "bknd-sync-types", + onBuilt: async () => { + if (!enabled) return; + app.emgr.onEvent( + App.Events.AppConfigUpdatedEvent, + async () => { + await write?.(new EntityTypescript(app.em)); + }, + { + id: "sync-types", + }, + ); + + if (firstBoot) { + firstBoot = false; + await write?.(new EntityTypescript(app.em)); + } + }, + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts new file mode 100644 index 0000000..ee7a31a --- /dev/null +++ b/app/src/plugins/index.ts @@ -0,0 +1,7 @@ +export { + cloudflareImageOptimization, + type CloudflareImageOptimizationOptions, +} from "./cloudflare/image-optimization.plugin"; +export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin"; +export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; +export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; From 28e277afe1f7a00a10780f6bd798005e98c32789 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 13 Jun 2025 08:30:54 +0200 Subject: [PATCH 4/5] updated cf image optimization plugin --- app/src/index.ts | 1 + app/src/plugins/cloudflare/image-optimization.plugin.ts | 6 ------ 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/app/src/index.ts b/app/src/index.ts index 8e75d16..f9c1e63 100644 --- a/app/src/index.ts +++ b/app/src/index.ts @@ -19,6 +19,7 @@ export { } from "./modules/ModuleManager"; export type { ServerEnv } from "modules/Controller"; +export type { BkndConfig } from "bknd/adapter"; export * as middlewares from "modules/middlewares"; export { registries } from "modules/registries"; diff --git a/app/src/plugins/cloudflare/image-optimization.plugin.ts b/app/src/plugins/cloudflare/image-optimization.plugin.ts index f023977..86fb93b 100644 --- a/app/src/plugins/cloudflare/image-optimization.plugin.ts +++ b/app/src/plugins/cloudflare/image-optimization.plugin.ts @@ -4,14 +4,12 @@ export type CloudflareImageOptimizationOptions = { accessUrl?: string; resolvePath?: string; autoFormat?: boolean; - devBypass?: string; }; export function cloudflareImageOptimization({ accessUrl = "/_plugin/image/optimize", resolvePath = "/api/media/file", autoFormat = true, - devBypass, }: CloudflareImageOptimizationOptions = {}): AppPlugin { const disallowedAccessUrls = ["/api", "/admin", "/_optimize"]; if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) { @@ -25,10 +23,6 @@ export function cloudflareImageOptimization({ const request = c.req.raw; const url = new URL(request.url); - if (devBypass) { - return c.redirect(devBypass + url.pathname + url.search, 302); - } - const storage = app.module.media?.storage; if (!storage) { throw new Error("No media storage configured"); From af6d1960b95c89e843783376e3e1aa21e2eb8516 Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 13 Jun 2025 17:27:58 +0200 Subject: [PATCH 5/5] fix tests --- app/__test__/App.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 5aa9ef1..e611304 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, test } from "bun:test"; -import { App } from "../src"; +import { afterEach, describe, test, expect } from "bun:test"; +import { App, createApp } from "core/test/utils"; import { getDummyConnection } from "./helper"; import { Hono } from "hono"; import * as proto from "../src/data/prototype"; @@ -18,7 +18,7 @@ describe("App tests", async () => { test("plugins", async () => { const called: string[] = []; - const app = App.create({ + const app = createApp({ initialConfig: { auth: { enabled: true,