Merge pull request #187 from bknd-io/feat/plugin-improvements

improved app plugins
This commit is contained in:
dswbx
2025-06-13 17:29:42 +02:00
committed by GitHub
27 changed files with 535 additions and 217 deletions

View File

@@ -1,6 +1,9 @@
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";
import { pick } from "lodash-es";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
@@ -10,18 +13,95 @@ 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 = createApp({
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"]);
},
);
},
onBoot: async () => {
called.push("onBoot");
},
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([
"onBoot",
"onServerInit",
"beforeBuild",
"onServerInit",
"schema",
"onFirstBoot",
"onBuilt",
]);
expect(app.plugins).toHaveLength(1);
expect(app.plugins.map((p) => p.name)).toEqual(["test"]);
});
});

View File

@@ -20,6 +20,7 @@ describe("App", () => {
"guard",
"flags",
"logger",
"helper",
]);
},
},

View File

@@ -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 extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
@@ -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",

View File

@@ -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>): 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<ModuleBuildContext>): ModuleBuildCon
logger: new DebugLogger(false),
...overrides,
};
return {
...ctx,
helper: new ModuleHelper(ctx as any),
} as any;
}
export function moduleTestSuite(module: { new (): Module }) {

View File

@@ -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],

View File

@@ -201,6 +201,11 @@
},
"require": "./dist/adapter/sqlite/node.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",
@@ -249,6 +254,24 @@
"./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"],
"sqlite": ["./dist/types/adapter/sqlite/edge.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"
},

View File

@@ -1,6 +1,7 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core";
import { Event } from "core/events";
import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import type { Hono } from "hono";
import {
@@ -14,12 +15,22 @@ 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";
export type AppPlugin = (app: App) => Promise<void> | void;
export type AppPluginConfig = {
name: string;
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
beforeBuild?: () => MaybePromise<void>;
onBuilt?: () => MaybePromise<void>;
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
onFirstBoot?: () => MaybePromise<void>;
onBoot?: () => MaybePromise<void>;
};
export type AppPlugin = (app: App) => AppPluginConfig;
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent {
@@ -66,9 +77,9 @@ export class App<C extends Connection = Connection> {
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
plugins: AppPluginConfig[];
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _building: boolean = false;
constructor(
@@ -76,13 +87,15 @@ export class App<C extends Connection = Connection> {
_initialConfig?: InitialModuleConfigs,
private options?: AppOptions,
) {
this.plugins = options?.plugins ?? [];
this.plugins = (options?.plugins ?? []).map((plugin) => plugin(this));
this.runPlugins("onBoot");
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);
}
@@ -91,6 +104,32 @@ export class App<C extends Connection = Connection> {
return this.modules.ctx().emgr;
}
protected async runPlugins<Key extends keyof AppPluginConfig>(
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) {
@@ -99,6 +138,8 @@ export class App<C extends Connection = Connection> {
}
if (!options?.forceBuild) return;
}
await this.runPlugins("beforeBuild");
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
@@ -110,13 +151,10 @@ export class App<C extends Connection = Connection> {
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) {
@@ -216,12 +254,13 @@ export class App<C extends Connection = Connection> {
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<ServerEnv>) {
protected async onServerInit(server: Hono<ServerEnv>) {
server.use(async (c, next) => {
c.set("app", this);
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
@@ -251,6 +290,23 @@ export class App<C extends Connection = Connection> {
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<typeof prototypeEm>;
}[];
if (results.length > 0) {
for (const { name, result } of results) {
if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result);
}
}
}
}
}

View File

@@ -140,7 +140,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
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<typeof authConfigSchema> {
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) {}
}

View File

@@ -184,6 +184,6 @@ export class AuthController extends Controller {
this.registerStrategyActions(strategy, hono);
}
return hono.all("*", (c) => c.notFound());
return hono;
}
}

View File

@@ -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";

View File

@@ -2,3 +2,5 @@ export interface Serializable<Class, Json extends object = object> {
toJSON(): Json;
fromJSON(json: Json): Class;
}
export type MaybePromise<T> = T | Promise<T>;

View File

@@ -196,7 +196,7 @@ export class DataController extends Controller {
},
);
return hono.all("*", (c) => c.notFound());
return hono;
}
private getEntityRoutes() {

View File

@@ -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);
}

View File

@@ -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]
*/

View File

@@ -18,6 +18,9 @@ export {
type InitialModuleConfigs,
} 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";

View File

@@ -51,7 +51,7 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
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"]);
}),

View File

@@ -301,6 +301,6 @@ export class MediaController extends Controller {
},
);
return hono.all("*", (c) => c.notFound());
return hono;
}
}

View File

@@ -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<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
@@ -141,80 +133,4 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
toJSON(secrets?: boolean): Static<ReturnType<(typeof this)["getSchema"]>> {
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 extends ReturnType<typeof prototypeEm>>(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);
}
}

View File

@@ -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<ModuleBuildContext, "helper">) {}
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 extends ReturnType<typeof prototypeEm>>(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);
}
}

View File

@@ -27,6 +27,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 };
@@ -85,6 +86,8 @@ export type ModuleManagerOptions = {
trustFetched?: 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>;
/** @deprecated */
verbosity?: Verbosity;
};
@@ -261,7 +264,7 @@ export class ModuleManager {
this.guard = new Guard();
}
return {
const ctx = {
connection: this.connection,
server: this.server,
em: this.em,
@@ -270,6 +273,11 @@ export class ModuleManager {
flags: Module.ctx_flags,
logger: this.logger,
};
return {
...ctx,
helper: new ModuleHelper(ctx),
};
}
private async fetch(): Promise<ConfigTable | undefined> {
@@ -543,6 +551,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;

View File

@@ -322,6 +322,7 @@ export class SystemController extends Controller {
local: datetimeStringLocal(),
utc: datetimeStringUTC(),
},
plugins: this.app.plugins.map((p) => p.name),
}),
);

View File

@@ -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(),
},
});
});
};
}

View File

@@ -0,0 +1,80 @@
import type { App, AppPlugin } from "bknd";
export type CloudflareImageOptimizationOptions = {
accessUrl?: string;
resolvePath?: string;
autoFormat?: boolean;
};
export function cloudflareImageOptimization({
accessUrl = "/_plugin/image/optimize",
resolvePath = "/api/media/file",
autoFormat = true,
}: CloudflareImageOptimizationOptions = {}): 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);
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(),
},
});
});
},
});
}

View File

@@ -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);
},
});
}

View File

@@ -0,0 +1,35 @@
import { App, type AppConfig, type AppPlugin } from "bknd";
export type SyncConfigOptions = {
enabled?: boolean;
includeSecrets?: boolean;
write: (config: AppConfig) => Promise<void>;
};
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));
}
},
});
}

View File

@@ -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<void>;
};
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));
}
},
});
}

7
app/src/plugins/index.ts Normal file
View File

@@ -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";