mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e923bff69 | |||
| c7bd0a636b | |||
| a9b2c613e1 | |||
| 6855e6f7e9 | |||
| b1a32f3705 | |||
| d8671355a6 | |||
| bd4bc14282 | |||
| 5823c2d245 | |||
| c732566f63 | |||
| 0d945ab45b | |||
| c06ba061f0 | |||
| 3bf92a8c65 | |||
| 87e07570d4 | |||
| c7d983942f | |||
| bb756548a6 | |||
| e94e8d8bd1 | |||
| 1d5f14fae0 | |||
| a8c20d3675 | |||
| 475563b5e1 | |||
| f0d502133e | |||
| 0500d4fc8e | |||
| 5bccc910a3 | |||
| da3f3d98d0 | |||
| c8745b3464 | |||
| eabd57cb4e | |||
| d182640981 | |||
| 138a3579cb | |||
| 99df7f1402 | |||
| 47f48be514 | |||
| 5c7bfeab8f | |||
| 7f337e25bb | |||
| fcab042e88 | |||
| aa86d54553 | |||
| 6026613d29 | |||
| 7d3d1e811f | |||
| 9f2c20ccee | |||
| 064bbba8aa | |||
| d1aa05a9bb | |||
| ab46611839 | |||
| 24226382e8 | |||
| d04c4a6191 | |||
| 06125f1afe | |||
| 8ef11aa382 | |||
| 76da14294c | |||
| c1e92e503b | |||
| 70e42a02d7 | |||
| a17fd2df67 | |||
| 0648f71a9e | |||
| 131b7dd52e | |||
| 3a79ce2cf8 | |||
| deddf00c38 | |||
| a7e3ce878a | |||
| d7ee13011f | |||
| 1d1ebff64d | |||
| ac2ae2657e | |||
| 8e987d58f2 | |||
| 386c0d3ff0 | |||
| 602235b372 | |||
| c4138ef823 | |||
| 1631bbb754 | |||
| 8c91dff94d | |||
| 50c5adce0c | |||
| 9d9aa7b7a5 | |||
| 43ec075a32 | |||
| 29ae6c6f9d | |||
| bd86f8ef91 | |||
| 25027429df | |||
| 35959aaf6d | |||
| 56fb779f99 | |||
| 134fbd6d34 | |||
| d2c75b1605 | |||
| a474e3fe3a | |||
| d81b3acb94 | |||
| 2ca66f4fc9 | |||
| a8bbb6e760 | |||
| 32459a1562 | |||
| 2395d7fe97 | |||
| b7ec4982dc | |||
| 5cca911e9d | |||
| 290498de6e |
@@ -14,9 +14,11 @@ packages/media/.env
|
||||
**/*/.env
|
||||
**/*/.dev.vars
|
||||
**/*/.wrangler
|
||||
**/*/*.tgz
|
||||
**/*/vite.config.ts.timestamp*
|
||||
.history
|
||||
**/*/.db/*
|
||||
**/*/.configs/*
|
||||
**/*/*.db
|
||||
**/*/*.db-shm
|
||||
**/*/*.db-wal
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ FSL-1.1-MIT
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2024 Webintex GmbH
|
||||
Copyright 2025 Dennis Senn
|
||||
|
||||
## Terms and Conditions
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
bknd simplifies app development by providing fully functional backend for data management,
|
||||
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type TSchema, Type, stripMark } from "../src/core/utils";
|
||||
import { Module } from "../src/modules/Module";
|
||||
|
||||
function createModule<Schema extends TSchema>(schema: Schema) {
|
||||
class TestModule extends Module<typeof schema> {
|
||||
getSchema() {
|
||||
return schema;
|
||||
}
|
||||
toJSON() {
|
||||
return this.config;
|
||||
}
|
||||
useForceParse() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return TestModule;
|
||||
}
|
||||
|
||||
describe("Module", async () => {
|
||||
test("basic", async () => {});
|
||||
|
||||
test("listener", async () => {
|
||||
let result: any;
|
||||
|
||||
const module = createModule(Type.Object({ a: Type.String() }));
|
||||
const m = new module({ a: "test" });
|
||||
|
||||
await m.schema().set({ a: "test2" });
|
||||
m.setListener(async (c) => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
result = stripMark(c);
|
||||
});
|
||||
await m.schema().set({ a: "test3" });
|
||||
expect(result).toEqual({ a: "test3" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { secureRandomString } from "../../src/core/utils";
|
||||
import { ModuleApi } from "../../src/modules";
|
||||
|
||||
class Api extends ModuleApi {
|
||||
_getUrl(path: string) {
|
||||
return this.getUrl(path);
|
||||
}
|
||||
}
|
||||
|
||||
const host = "http://localhost";
|
||||
|
||||
describe("ModuleApi", () => {
|
||||
it("resolves options correctly", () => {
|
||||
const api = new Api({ host });
|
||||
expect(api.options).toEqual({ host });
|
||||
});
|
||||
|
||||
it("returns correct url from path", () => {
|
||||
const api = new Api({ host });
|
||||
expect(api._getUrl("/test")).toEqual("http://localhost/test");
|
||||
expect(api._getUrl("test")).toEqual("http://localhost/test");
|
||||
expect(api._getUrl("test/")).toEqual("http://localhost/test");
|
||||
expect(api._getUrl("//test?foo=1")).toEqual("http://localhost/test?foo=1");
|
||||
});
|
||||
|
||||
it("fetches endpoint", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
|
||||
const res = await api.get("/endpoint");
|
||||
expect(res.res.ok).toEqual(true);
|
||||
expect(res.res.status).toEqual(200);
|
||||
expect(res.data).toEqual({ foo: "bar" });
|
||||
expect(res.body).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("has accessible request", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
|
||||
const promise = api.get("/endpoint");
|
||||
expect(promise.request).toBeDefined();
|
||||
expect(promise.request.url).toEqual("http://localhost/endpoint");
|
||||
|
||||
expect((await promise).body).toEqual({ foo: "bar" });
|
||||
});
|
||||
|
||||
it("adds token to headers when given in options", () => {
|
||||
const token = secureRandomString(20);
|
||||
const api = new Api({ host, token, token_transport: "header" });
|
||||
|
||||
expect(api.get("/").request.headers.get("Authorization")).toEqual(`Bearer ${token}`);
|
||||
});
|
||||
|
||||
it("sets header to accept json", () => {
|
||||
const api = new Api({ host });
|
||||
expect(api.get("/").request.headers.get("Accept")).toEqual("application/json");
|
||||
});
|
||||
|
||||
it("adds additional headers from options", () => {
|
||||
const headers = new Headers({
|
||||
"X-Test": "123"
|
||||
});
|
||||
const api = new Api({ host, headers });
|
||||
expect(api.get("/").request.headers.get("X-Test")).toEqual("123");
|
||||
});
|
||||
|
||||
it("uses basepath & removes trailing slash", () => {
|
||||
const api = new Api({ host, basepath: "/api" });
|
||||
expect(api.get("/").request.url).toEqual("http://localhost/api");
|
||||
});
|
||||
|
||||
it("uses search params", () => {
|
||||
const api = new Api({ host });
|
||||
const search = new URLSearchParams({
|
||||
foo: "bar"
|
||||
});
|
||||
expect(api.get("/", search).request.url).toEqual("http://localhost/?" + search.toString());
|
||||
});
|
||||
|
||||
it("resolves method shortcut fns correctly", () => {
|
||||
const api = new Api({ host });
|
||||
expect(api.get("/").request.method).toEqual("GET");
|
||||
expect(api.post("/").request.method).toEqual("POST");
|
||||
expect(api.put("/").request.method).toEqual("PUT");
|
||||
expect(api.patch("/").request.method).toEqual("PATCH");
|
||||
expect(api.delete("/").request.method).toEqual("DELETE");
|
||||
});
|
||||
|
||||
// @todo: test error response
|
||||
// @todo: test method shortcut functions
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { describe, test } from "bun:test";
|
||||
import { DataApi } from "../../src/modules/data/api/DataApi";
|
||||
|
||||
describe("Api", async () => {
|
||||
test("...", async () => {
|
||||
/*const dataApi = new DataApi({
|
||||
host: "https://dev-config-soma.bknd.run"
|
||||
});
|
||||
|
||||
const one = await dataApi.readOne("users", 1);
|
||||
const many = await dataApi.readMany("users", { limit: 2 });
|
||||
console.log("one", one);
|
||||
console.log("many", many);*/
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { TObject, TString } from "@sinclair/typebox";
|
||||
import { Registry } from "../../src/core/registry/Registry";
|
||||
import { type TSchema, Type } from "../../src/core/utils";
|
||||
@@ -11,6 +11,9 @@ class What {
|
||||
method() {
|
||||
return null;
|
||||
}
|
||||
getType() {
|
||||
return Type.Object({ type: Type.String() });
|
||||
}
|
||||
}
|
||||
class What2 extends What {}
|
||||
class NotAllowed {}
|
||||
@@ -32,25 +35,53 @@ describe("Registry", () => {
|
||||
} satisfies Record<string, Test1>);
|
||||
|
||||
const item = registry.get("first");
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.cls).toBe(What);
|
||||
|
||||
const second = Type.Object({ type: Type.String(), what: Type.String() });
|
||||
registry.add("second", {
|
||||
cls: What2,
|
||||
schema: Type.Object({ type: Type.String(), what: Type.String() }),
|
||||
schema: second,
|
||||
enabled: true
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(registry.get("second").schema).toEqual(second);
|
||||
|
||||
const third = Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() });
|
||||
registry.add("third", {
|
||||
// @ts-expect-error
|
||||
cls: NotAllowed,
|
||||
schema: Type.Object({ type: Type.String({ default: "1" }), what22: Type.String() }),
|
||||
schema: third,
|
||||
enabled: true
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(registry.get("third").schema).toEqual(third);
|
||||
|
||||
const fourth = Type.Object({ type: Type.Number(), what22: Type.String() });
|
||||
registry.add("fourth", {
|
||||
cls: What,
|
||||
// @ts-expect-error
|
||||
schema: Type.Object({ type: Type.Number(), what22: Type.String() }),
|
||||
schema: fourth,
|
||||
enabled: true
|
||||
});
|
||||
// @ts-ignore
|
||||
expect(registry.get("fourth").schema).toEqual(fourth);
|
||||
|
||||
console.log("list", registry.all());
|
||||
expect(Object.keys(registry.all()).length).toBe(4);
|
||||
});
|
||||
|
||||
test("uses registration fn", async () => {
|
||||
const registry = new Registry<Test1>((a: ClassRef<What>) => {
|
||||
return {
|
||||
cls: a,
|
||||
schema: a.prototype.getType(),
|
||||
enabled: true
|
||||
};
|
||||
});
|
||||
|
||||
registry.register("what2", What2);
|
||||
expect(registry.get("what2")).toBeDefined();
|
||||
expect(registry.get("what2").cls).toBe(What2);
|
||||
expect(registry.get("what2").schema).toEqual(What2.prototype.getType());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { describe, test } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { checksum, hash } from "../../src/core/utils";
|
||||
|
||||
describe("crypto", async () => {
|
||||
test("sha256", async () => {
|
||||
console.log(await hash.sha256("test"));
|
||||
expect(await hash.sha256("test")).toBe(
|
||||
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
|
||||
);
|
||||
});
|
||||
test("sha1", async () => {
|
||||
console.log(await hash.sha1("test"));
|
||||
expect(await hash.sha1("test")).toBe("a94a8fe5ccb19ba61c4c0873d391e987982fbbd3");
|
||||
});
|
||||
test("checksum", async () => {
|
||||
console.log(checksum("hello world"));
|
||||
expect(await checksum("hello world")).toBe("2aae6c35c94fcfb415dbe95f408b9ce91ee846ed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { QueryObject } from "ufo";
|
||||
import { WhereBuilder, type WhereQuery } from "../../src/data/entities/query/WhereBuilder";
|
||||
import { Value } from "../../src/core/utils";
|
||||
import { WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
||||
import { getDummyConnection } from "./helper";
|
||||
|
||||
const t = "t";
|
||||
describe("data-query-impl", () => {
|
||||
function qb() {
|
||||
const c = getDummyConnection();
|
||||
const kysely = c.dummyConnection.kysely;
|
||||
return kysely.selectFrom(t).selectAll();
|
||||
return kysely.selectFrom("t").selectAll();
|
||||
}
|
||||
function compile(q: QueryObject) {
|
||||
function compile(q: WhereQuery) {
|
||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||
return { sql, parameters };
|
||||
}
|
||||
@@ -90,3 +89,20 @@ describe("data-query-impl", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("data-query-impl: Typebox", () => {
|
||||
test("sort", async () => {
|
||||
const decode = (input: any, expected: any) => {
|
||||
const result = Value.Decode(querySchema, input);
|
||||
expect(result.sort).toEqual(expected);
|
||||
};
|
||||
const _dflt = { by: "id", dir: "asc" };
|
||||
|
||||
decode({ sort: "" }, _dflt);
|
||||
decode({ sort: "name" }, { by: "name", dir: "asc" });
|
||||
decode({ sort: "-name" }, { by: "name", dir: "desc" });
|
||||
decode({ sort: "-posts.name" }, { by: "posts.name", dir: "desc" });
|
||||
decode({ sort: "-1name" }, _dflt);
|
||||
decode({ sort: { by: "name", dir: "desc" } }, { by: "name", dir: "desc" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("some tests", async () => {
|
||||
|
||||
const users = new Entity("users", [
|
||||
new TextField("username", { required: true, default_value: "nobody" }),
|
||||
new TextField("email", { max_length: 3 })
|
||||
new TextField("email", { maxLength: 3 })
|
||||
]);
|
||||
|
||||
const posts = new Entity("posts", [
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("Mutator simple", async () => {
|
||||
new TextField("label", { required: true, minLength: 1 }),
|
||||
new NumberField("count", { default_value: 0 })
|
||||
]);
|
||||
const em = new EntityManager([items], connection);
|
||||
const em = new EntityManager<any>([items], connection);
|
||||
|
||||
await em.connection.kysely.schema
|
||||
.createTable("items")
|
||||
@@ -175,4 +175,18 @@ describe("Mutator simple", async () => {
|
||||
{ id: 8, label: "keep", count: 0 }
|
||||
]);
|
||||
});
|
||||
|
||||
test("insertMany", async () => {
|
||||
const oldCount = (await em.repo(items).count()).count;
|
||||
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
|
||||
const { data } = await em.mutator(items).insertMany(inserts);
|
||||
|
||||
expect(data.length).toBe(2);
|
||||
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
|
||||
const newCount = (await em.repo(items).count()).count;
|
||||
expect(newCount).toBe(oldCount + inserts.length);
|
||||
|
||||
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
|
||||
expect(data2).toEqual(data);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
BooleanField,
|
||||
DateField,
|
||||
Entity,
|
||||
EntityIndex,
|
||||
EntityManager,
|
||||
EnumField,
|
||||
JsonField,
|
||||
ManyToManyRelation,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
PolymorphicRelation,
|
||||
TextField
|
||||
} from "../../src/data";
|
||||
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||
import {
|
||||
FieldPrototype,
|
||||
type FieldSchema,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
boolean,
|
||||
date,
|
||||
datetime,
|
||||
em,
|
||||
entity,
|
||||
enumm,
|
||||
json,
|
||||
@@ -46,12 +50,17 @@ describe("prototype", () => {
|
||||
});
|
||||
|
||||
test("...2", async () => {
|
||||
const user = entity("users", {
|
||||
name: text().required(),
|
||||
const users = entity("users", {
|
||||
name: text(),
|
||||
bio: text(),
|
||||
age: number(),
|
||||
some: number().required()
|
||||
some: number()
|
||||
});
|
||||
type db = {
|
||||
users: Schema<typeof users>;
|
||||
};
|
||||
|
||||
const obj: Schema<typeof users> = {} as any;
|
||||
|
||||
//console.log("user", user.toJSON());
|
||||
});
|
||||
@@ -266,4 +275,38 @@ describe("prototype", () => {
|
||||
|
||||
const obj: Schema<typeof test> = {} as any;
|
||||
});
|
||||
|
||||
test("schema", async () => {
|
||||
const _em = em(
|
||||
{
|
||||
posts: entity("posts", { name: text(), slug: text().required() }),
|
||||
comments: entity("comments", { some: text() }),
|
||||
users: entity("users", { email: text() })
|
||||
},
|
||||
({ relation, index }, { posts, comments, users }) => {
|
||||
relation(posts).manyToOne(comments).manyToOne(users);
|
||||
index(posts).on(["name"]).on(["slug"], true);
|
||||
}
|
||||
);
|
||||
|
||||
type LocalDb = (typeof _em)["DB"];
|
||||
|
||||
const es = [
|
||||
new Entity("posts", [new TextField("name"), new TextField("slug", { required: true })]),
|
||||
new Entity("comments", [new TextField("some")]),
|
||||
new Entity("users", [new TextField("email")])
|
||||
];
|
||||
const _em2 = new EntityManager(
|
||||
es,
|
||||
new DummyConnection(),
|
||||
[new ManyToOneRelation(es[0], es[1]), new ManyToOneRelation(es[0], es[2])],
|
||||
[
|
||||
new EntityIndex(es[0], [es[0].field("name")!]),
|
||||
new EntityIndex(es[0], [es[0].field("slug")!], true)
|
||||
]
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
expect(_em2.toJSON()).toEqual(_em.toJSON());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("[data] Mutator (base)", async () => {
|
||||
new TextField("hidden", { hidden: true }),
|
||||
new TextField("not_fillable", { fillable: false })
|
||||
]);
|
||||
const em = new EntityManager([entity], dummyConnection);
|
||||
const em = new EntityManager<any>([entity], dummyConnection);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
const payload = { label: "item 1", count: 1 };
|
||||
@@ -61,7 +61,7 @@ describe("[data] Mutator (ManyToOne)", async () => {
|
||||
const posts = new Entity("posts", [new TextField("title")]);
|
||||
const users = new Entity("users", [new TextField("username")]);
|
||||
const relations = [new ManyToOneRelation(posts, users)];
|
||||
const em = new EntityManager([posts, users], dummyConnection, relations);
|
||||
const em = new EntityManager<any>([posts, users], dummyConnection, relations);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
test("RelationMutator", async () => {
|
||||
@@ -192,7 +192,7 @@ describe("[data] Mutator (OneToOne)", async () => {
|
||||
const users = new Entity("users", [new TextField("username")]);
|
||||
const settings = new Entity("settings", [new TextField("theme")]);
|
||||
const relations = [new OneToOneRelation(users, settings)];
|
||||
const em = new EntityManager([users, settings], dummyConnection, relations);
|
||||
const em = new EntityManager<any>([users, settings], dummyConnection, relations);
|
||||
await em.schema().sync({ force: true });
|
||||
|
||||
test("insertOne: missing ref", async () => {
|
||||
@@ -276,7 +276,7 @@ describe("[data] Mutator (ManyToMany)", async () => {
|
||||
|
||||
describe("[data] Mutator (Events)", async () => {
|
||||
const entity = new Entity("test", [new TextField("label")]);
|
||||
const em = new EntityManager([entity], dummyConnection);
|
||||
const em = new EntityManager<any>([entity], dummyConnection);
|
||||
await em.schema().sync({ force: true });
|
||||
const events = new Map<string, any>();
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { type Static, Type, _jsonp } from "../../src/core/utils";
|
||||
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
|
||||
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
||||
|
||||
/*beforeAll(disableConsoleLog);
|
||||
@@ -232,7 +232,9 @@ describe("Flow tests", async () => {
|
||||
).toEqual(["second", "fourth"]);
|
||||
|
||||
const execution = back.createExecution();
|
||||
expect(execution.start()).rejects.toThrow();
|
||||
withDisabledConsole(async () => {
|
||||
expect(execution.start()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("Flow with back step: enough retries", async () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ const _oldConsoles = {
|
||||
error: console.error
|
||||
};
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { App, createApp } from "../../src";
|
||||
import type { AuthResponse } from "../../src/auth";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
|
||||
beforeAll(disableConsoleLog);
|
||||
afterAll(enableConsoleLog);
|
||||
|
||||
const roles = {
|
||||
sloppy: {
|
||||
guest: {
|
||||
permissions: [
|
||||
"system.access.admin",
|
||||
"system.schema.read",
|
||||
"system.access.api",
|
||||
"system.config.read",
|
||||
"data.entity.read"
|
||||
],
|
||||
is_default: true
|
||||
},
|
||||
admin: {
|
||||
is_default: true,
|
||||
implicit_allow: true
|
||||
}
|
||||
},
|
||||
strict: {
|
||||
guest: {
|
||||
permissions: ["system.access.api", "system.config.read", "data.entity.read"],
|
||||
is_default: true
|
||||
},
|
||||
admin: {
|
||||
is_default: true,
|
||||
implicit_allow: true
|
||||
}
|
||||
}
|
||||
};
|
||||
const configs = {
|
||||
auth: {
|
||||
enabled: true,
|
||||
entity_name: "users",
|
||||
jwt: {
|
||||
secret: secureRandomString(20),
|
||||
issuer: randomString(10)
|
||||
},
|
||||
roles: roles.strict,
|
||||
guard: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
users: {
|
||||
normal: {
|
||||
email: "normal@bknd.io",
|
||||
password: "12345678"
|
||||
},
|
||||
admin: {
|
||||
email: "admin@bknd.io",
|
||||
password: "12345678",
|
||||
role: "admin"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function createAuthApp() {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
auth: configs.auth
|
||||
}
|
||||
});
|
||||
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppFirstBoot,
|
||||
async () => {
|
||||
await app.createUser(configs.users.normal);
|
||||
await app.createUser(configs.users.admin);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function getCookie(r: Response, name: string) {
|
||||
const cookies = r.headers.get("cookie") ?? r.headers.get("set-cookie");
|
||||
if (!cookies) return;
|
||||
const cookie = cookies.split(";").find((c) => c.trim().startsWith(name));
|
||||
if (!cookie) return;
|
||||
return cookie.split("=")[1];
|
||||
}
|
||||
|
||||
const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) => {
|
||||
function headers(token?: string, additional?: Record<string, string>) {
|
||||
if (mode === "cookie") {
|
||||
return {
|
||||
cookie: `auth=${token};`,
|
||||
...additional
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...additional
|
||||
};
|
||||
}
|
||||
function body(obj?: Record<string, any>) {
|
||||
if (mode === "cookie") {
|
||||
const formData = new FormData();
|
||||
for (const key in obj) {
|
||||
formData.append(key, obj[key]);
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
return {
|
||||
login: async (
|
||||
user: any
|
||||
): Promise<{ res: Response; data: Mode extends "token" ? AuthResponse : string }> => {
|
||||
const res = (await app.server.request("/api/auth/password/login", {
|
||||
method: "POST",
|
||||
headers: headers(),
|
||||
body: body(user)
|
||||
})) as Response;
|
||||
|
||||
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||
|
||||
return { res, data };
|
||||
},
|
||||
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||
const res = (await app.server.request("/api/auth/me", {
|
||||
method: "GET",
|
||||
headers: headers(token)
|
||||
})) as Response;
|
||||
return await res.json();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
describe("integration auth", () => {
|
||||
it("should create users on boot", async () => {
|
||||
const app = createAuthApp();
|
||||
await app.build();
|
||||
|
||||
const { data: users } = await app.em.repository("users").findMany();
|
||||
expect(users.length).toBe(2);
|
||||
expect(users[0].email).toBe(configs.users.normal.email);
|
||||
expect(users[1].email).toBe(configs.users.admin.email);
|
||||
});
|
||||
|
||||
it("should log you in with API", async () => {
|
||||
const app = createAuthApp();
|
||||
await app.build();
|
||||
const $fns = fns(app);
|
||||
|
||||
// login api
|
||||
const { data } = await $fns.login(configs.users.normal);
|
||||
const me = await $fns.me(data.token);
|
||||
|
||||
expect(data.user.email).toBe(me.user.email);
|
||||
expect(me.user.email).toBe(configs.users.normal.email);
|
||||
|
||||
// expect no user with no token
|
||||
expect(await $fns.me()).toEqual({ user: null as any });
|
||||
|
||||
// expect no user with invalid token
|
||||
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||
expect(await $fns.me()).toEqual({ user: null as any });
|
||||
});
|
||||
|
||||
it("should log you in with form and cookie", async () => {
|
||||
const app = createAuthApp();
|
||||
await app.build();
|
||||
const $fns = fns(app, "cookie");
|
||||
|
||||
const { res, data: token } = await $fns.login(configs.users.normal);
|
||||
expect(token).toBeDefined();
|
||||
expect(res.status).toBe(302); // because it redirects
|
||||
|
||||
// test cookie jwt interchangability
|
||||
{
|
||||
// expect token to not work as-is for api endpoints
|
||||
expect(await fns(app).me(token)).toEqual({ user: null as any });
|
||||
// hono adds an additional segment to cookies
|
||||
const apified_token = token.split(".").slice(0, -1).join(".");
|
||||
// now it should work
|
||||
// @todo: maybe add a config to don't allow re-use?
|
||||
expect((await fns(app).me(apified_token)).user.email).toBe(configs.users.normal.email);
|
||||
}
|
||||
|
||||
// test cookie with me endpoint
|
||||
{
|
||||
const me = await $fns.me(token);
|
||||
expect(me.user.email).toBe(configs.users.normal.email);
|
||||
|
||||
// check with invalid & empty
|
||||
expect(await $fns.me("invalid")).toEqual({ user: null as any });
|
||||
expect(await $fns.me()).toEqual({ user: null as any });
|
||||
}
|
||||
});
|
||||
|
||||
it("should check for permissions", async () => {
|
||||
const app = createAuthApp();
|
||||
await app.build();
|
||||
|
||||
await withDisabledConsole(async () => {
|
||||
const res = await app.server.request("/api/system/schema");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import * as large from "../../src/media/storage/mime-types";
|
||||
import * as tiny from "../../src/media/storage/mime-types-tiny";
|
||||
|
||||
describe("media/mime-types", () => {
|
||||
test("tiny resolves", () => {
|
||||
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
|
||||
|
||||
for (const [ext, mime] of tests) {
|
||||
expect(tiny.guess(ext)).toBe(mime);
|
||||
}
|
||||
});
|
||||
|
||||
test("all tiny resolves to large", () => {
|
||||
for (const [ext, mime] of Object.entries(tiny.M)) {
|
||||
expect(large.guessMimeType("." + ext)).toBe(mime);
|
||||
}
|
||||
|
||||
for (const [type, exts] of Object.entries(tiny.Q)) {
|
||||
for (const ext of exts) {
|
||||
const ex = `${type}/${ext}`;
|
||||
try {
|
||||
expect(large.guessMimeType("." + ext)).toBe(ex);
|
||||
} catch (e) {
|
||||
console.log(`Failed for ${ext}`, {
|
||||
type,
|
||||
exts,
|
||||
ext,
|
||||
expected: ex,
|
||||
actual: large.guessMimeType("." + ext)
|
||||
});
|
||||
throw new Error(`Failed for ${ext}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { createApp } from "../../src";
|
||||
import { AuthController } from "../../src/auth/api/AuthController";
|
||||
import { em, entity, text } from "../../src/data";
|
||||
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||
@@ -76,4 +78,53 @@ describe("AppAuth", () => {
|
||||
expect(users[0].email).toBe("some@body.com");
|
||||
}
|
||||
});
|
||||
|
||||
test("registers auth middleware for bknd routes only", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "123456"
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await app.build();
|
||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||
|
||||
// register custom route
|
||||
app.server.get("/test", async (c) => c.text("test"));
|
||||
|
||||
// call a system api and then the custom route
|
||||
await app.server.request("/api/system/ping");
|
||||
await app.server.request("/test");
|
||||
|
||||
expect(spy.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
test("should allow additional user fields", async () => {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
auth: {
|
||||
entity_name: "users",
|
||||
enabled: true
|
||||
},
|
||||
data: em({
|
||||
users: entity("users", {
|
||||
additional: text()
|
||||
})
|
||||
}).toJSON()
|
||||
}
|
||||
});
|
||||
|
||||
await app.build();
|
||||
|
||||
const e = app.modules.em.entity("users");
|
||||
const fields = e.fields.map((f) => f.name);
|
||||
expect(e.type).toBe("system");
|
||||
expect(fields).toContain("additional");
|
||||
expect(fields).toEqual(["id", "email", "strategy", "strategy_value", "role", "additional"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,55 @@
|
||||
import { describe } from "bun:test";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createApp, registries } from "../../src";
|
||||
import { em, entity, text } from "../../src/data";
|
||||
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
|
||||
import { AppMedia } from "../../src/modules";
|
||||
import { moduleTestSuite } from "./module-test-suite";
|
||||
|
||||
describe("AppMedia", () => {
|
||||
moduleTestSuite(AppMedia);
|
||||
|
||||
test("should allow additional fields", async () => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
media: {
|
||||
entity_name: "media",
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: "./"
|
||||
}
|
||||
}
|
||||
},
|
||||
data: em({
|
||||
media: entity("media", {
|
||||
additional: text()
|
||||
})
|
||||
}).toJSON()
|
||||
}
|
||||
});
|
||||
|
||||
await app.build();
|
||||
|
||||
const e = app.modules.em.entity("media");
|
||||
const fields = e.fields.map((f) => f.name);
|
||||
expect(e.type).toBe("system");
|
||||
expect(fields).toContain("additional");
|
||||
expect(fields).toEqual([
|
||||
"id",
|
||||
"path",
|
||||
"folder",
|
||||
"mime_type",
|
||||
"size",
|
||||
"scope",
|
||||
"etag",
|
||||
"modified_at",
|
||||
"reference",
|
||||
"entity_id",
|
||||
"metadata",
|
||||
"additional"
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { type TSchema, Type, stripMark } from "../../src/core/utils";
|
||||
import { EntityManager, em, entity, index, text } from "../../src/data";
|
||||
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||
import { Module } from "../../src/modules/Module";
|
||||
|
||||
function createModule<Schema extends TSchema>(schema: Schema) {
|
||||
class TestModule extends Module<typeof schema> {
|
||||
getSchema() {
|
||||
return schema;
|
||||
}
|
||||
toJSON() {
|
||||
return this.config;
|
||||
}
|
||||
useForceParse() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return TestModule;
|
||||
}
|
||||
|
||||
describe("Module", async () => {
|
||||
describe("basic", () => {
|
||||
test("listener", async () => {
|
||||
let result: any;
|
||||
|
||||
const module = createModule(Type.Object({ a: Type.String() }));
|
||||
const m = new module({ a: "test" });
|
||||
|
||||
await m.schema().set({ a: "test2" });
|
||||
m.setListener(async (c) => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
result = stripMark(c);
|
||||
});
|
||||
await m.schema().set({ a: "test3" });
|
||||
expect(result).toEqual({ a: "test3" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("db schema", () => {
|
||||
class M extends Module {
|
||||
override getSchema() {
|
||||
return Type.Object({});
|
||||
}
|
||||
|
||||
prt = {
|
||||
ensureEntity: this.ensureEntity.bind(this),
|
||||
ensureIndex: this.ensureIndex.bind(this),
|
||||
ensureSchema: this.ensureSchema.bind(this)
|
||||
};
|
||||
|
||||
get em() {
|
||||
return this.ctx.em;
|
||||
}
|
||||
}
|
||||
|
||||
function make(_em: ReturnType<typeof em>) {
|
||||
const em = new EntityManager(
|
||||
Object.values(_em.entities),
|
||||
new DummyConnection(),
|
||||
_em.relations,
|
||||
_em.indices
|
||||
);
|
||||
return new M({} as any, { em, flags: Module.ctx_flags } as any);
|
||||
}
|
||||
function flat(_em: EntityManager) {
|
||||
return {
|
||||
entities: _em.entities.map((e) => ({
|
||||
name: e.name,
|
||||
fields: e.fields.map((f) => f.name),
|
||||
type: e.type
|
||||
})),
|
||||
indices: _em.indices.map((i) => ({
|
||||
name: i.name,
|
||||
entity: i.entity.name,
|
||||
fields: i.fields.map((f) => f.name),
|
||||
unique: i.unique
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
test("no change", () => {
|
||||
const initial = em({});
|
||||
|
||||
const m = make(initial);
|
||||
expect(m.ctx.flags.sync_required).toBe(false);
|
||||
|
||||
expect(flat(make(initial).em)).toEqual({
|
||||
entities: [],
|
||||
indices: []
|
||||
});
|
||||
});
|
||||
|
||||
test("init", () => {
|
||||
const initial = em({
|
||||
users: entity("u", {
|
||||
name: text()
|
||||
})
|
||||
});
|
||||
|
||||
const m = make(initial);
|
||||
expect(m.ctx.flags.sync_required).toBe(false);
|
||||
|
||||
expect(flat(m.em)).toEqual({
|
||||
entities: [
|
||||
{
|
||||
name: "u",
|
||||
fields: ["id", "name"],
|
||||
type: "regular"
|
||||
}
|
||||
],
|
||||
indices: []
|
||||
});
|
||||
});
|
||||
|
||||
test("ensure entity", () => {
|
||||
const initial = em({
|
||||
users: entity("u", {
|
||||
name: text()
|
||||
})
|
||||
});
|
||||
|
||||
const m = make(initial);
|
||||
expect(flat(m.em)).toEqual({
|
||||
entities: [
|
||||
{
|
||||
name: "u",
|
||||
fields: ["id", "name"],
|
||||
type: "regular"
|
||||
}
|
||||
],
|
||||
indices: []
|
||||
});
|
||||
|
||||
// this should add a new entity
|
||||
m.prt.ensureEntity(
|
||||
entity("p", {
|
||||
title: text()
|
||||
})
|
||||
);
|
||||
|
||||
// this should only add the field "important"
|
||||
m.prt.ensureEntity(
|
||||
entity(
|
||||
"u",
|
||||
{
|
||||
important: text()
|
||||
},
|
||||
undefined,
|
||||
"system"
|
||||
)
|
||||
);
|
||||
|
||||
expect(m.ctx.flags.sync_required).toBe(true);
|
||||
expect(flat(m.em)).toEqual({
|
||||
entities: [
|
||||
{
|
||||
name: "u",
|
||||
// ensured properties must come first
|
||||
fields: ["id", "important", "name"],
|
||||
// ensured type must be present
|
||||
type: "system"
|
||||
},
|
||||
{
|
||||
name: "p",
|
||||
fields: ["id", "title"],
|
||||
type: "regular"
|
||||
}
|
||||
],
|
||||
indices: []
|
||||
});
|
||||
});
|
||||
|
||||
test("ensure index", () => {
|
||||
const users = entity("u", {
|
||||
name: text(),
|
||||
title: text()
|
||||
});
|
||||
const initial = em({ users }, ({ index }, { users }) => {
|
||||
index(users).on(["title"]);
|
||||
});
|
||||
|
||||
const m = make(initial);
|
||||
m.prt.ensureIndex(index(users).on(["name"]));
|
||||
|
||||
expect(m.ctx.flags.sync_required).toBe(true);
|
||||
expect(flat(m.em)).toEqual({
|
||||
entities: [
|
||||
{
|
||||
name: "u",
|
||||
fields: ["id", "name", "title"],
|
||||
type: "regular"
|
||||
}
|
||||
],
|
||||
indices: [
|
||||
{
|
||||
name: "idx_u_title",
|
||||
entity: "u",
|
||||
fields: ["title"],
|
||||
unique: false
|
||||
},
|
||||
{
|
||||
name: "idx_u_name",
|
||||
entity: "u",
|
||||
fields: ["name"],
|
||||
unique: false
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mark, stripMark } from "../src/core/utils";
|
||||
import { entity, text } from "../src/data";
|
||||
import { ModuleManager, getDefaultConfig } from "../src/modules/ModuleManager";
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "../src/modules/migrations";
|
||||
import { getDummyConnection } from "./helper";
|
||||
import { stripMark } from "../../src/core/utils";
|
||||
import { entity, text } from "../../src/data";
|
||||
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
describe("ModuleManager", async () => {
|
||||
test("s1: no config, no build", async () => {
|
||||
@@ -5,7 +5,7 @@ import { Guard } from "../../src/auth";
|
||||
import { EventManager } from "../../src/core/events";
|
||||
import { Default, stripMark } from "../../src/core/utils";
|
||||
import { EntityManager } from "../../src/data";
|
||||
import type { Module, ModuleBuildContext } from "../../src/modules/Module";
|
||||
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
|
||||
import { getDummyConnection } from "../helper";
|
||||
|
||||
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
||||
@@ -16,6 +16,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
||||
em: new EntityManager([], dummyConnection),
|
||||
emgr: new EventManager(),
|
||||
guard: new Guard(),
|
||||
flags: Module.ctx_flags,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
+62
-91
@@ -1,82 +1,49 @@
|
||||
import { $ } from "bun";
|
||||
import * as esbuild from "esbuild";
|
||||
import postcss from "esbuild-postcss";
|
||||
import * as tsup from "tsup";
|
||||
import { guessMimeType } from "./src/media/storage/mime-types";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const watch = args.includes("--watch");
|
||||
const minify = args.includes("--minify");
|
||||
const types = args.includes("--types");
|
||||
const sourcemap = args.includes("--sourcemap");
|
||||
const clean = args.includes("--clean");
|
||||
|
||||
if (clean) {
|
||||
console.log("Cleaning dist (w/o static)");
|
||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||
}
|
||||
|
||||
let types_running = false;
|
||||
function buildTypes() {
|
||||
if (types_running) return;
|
||||
types_running = true;
|
||||
|
||||
await $`rm -rf dist`;
|
||||
if (types) {
|
||||
Bun.spawn(["bun", "build:types"], {
|
||||
stdout: "inherit",
|
||||
onExit: () => {
|
||||
console.log("Types built");
|
||||
Bun.spawn(["bun", "tsc-alias"], {
|
||||
stdout: "inherit",
|
||||
onExit: () => {
|
||||
console.log("Types aliased");
|
||||
types_running = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build static assets
|
||||
* Using esbuild because tsup doesn't include "react"
|
||||
*/
|
||||
const result = await esbuild.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
entryPoints: ["src/ui/main.tsx"],
|
||||
entryNames: "[dir]/[name]-[hash]",
|
||||
outdir: "dist/static",
|
||||
platform: "browser",
|
||||
bundle: true,
|
||||
splitting: true,
|
||||
metafile: true,
|
||||
drop: ["console", "debugger"],
|
||||
inject: ["src/ui/inject.js"],
|
||||
target: "es2022",
|
||||
format: "esm",
|
||||
plugins: [postcss()],
|
||||
loader: {
|
||||
".svg": "dataurl",
|
||||
".js": "jsx"
|
||||
},
|
||||
define: {
|
||||
__isDev: "0",
|
||||
"process.env.NODE_ENV": '"production"'
|
||||
},
|
||||
chunkNames: "chunks/[name]-[hash]"
|
||||
});
|
||||
|
||||
// Write manifest
|
||||
{
|
||||
const manifest: Record<string, object> = {};
|
||||
const toAsset = (output: string) => {
|
||||
const name = output.split("/").pop()!;
|
||||
return {
|
||||
name,
|
||||
path: output,
|
||||
mime: guessMimeType(name)
|
||||
};
|
||||
};
|
||||
|
||||
const info = Object.entries(result.metafile.outputs)
|
||||
.filter(([, meta]) => {
|
||||
return meta.entryPoint && meta.entryPoint === "src/ui/main.tsx";
|
||||
})
|
||||
.map(([output, meta]) => ({ output, meta }));
|
||||
|
||||
for (const { output, meta } of info) {
|
||||
manifest[meta.entryPoint as string] = toAsset(output);
|
||||
if (meta.cssBundle) {
|
||||
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
|
||||
}
|
||||
let watcher_timeout: any;
|
||||
function delayTypes() {
|
||||
if (!watch || !types) return;
|
||||
if (watcher_timeout) {
|
||||
clearTimeout(watcher_timeout);
|
||||
}
|
||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||
}
|
||||
|
||||
const manifest_file = "dist/static/manifest.json";
|
||||
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
|
||||
console.log(`Manifest written to ${manifest_file}`, manifest);
|
||||
if (types && !watch) {
|
||||
buildTypes();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,11 +58,14 @@ await tsup.build({
|
||||
external: ["bun:test", "@libsql/client"],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm", "cjs"],
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -106,22 +76,35 @@ await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css"],
|
||||
entry: [
|
||||
"src/ui/index.ts",
|
||||
"src/ui/client/index.ts",
|
||||
"src/ui/elements/index.ts",
|
||||
"src/ui/main.css"
|
||||
],
|
||||
outDir: "dist/ui",
|
||||
external: ["bun:test", "react", "react-dom", "use-sync-external-store"],
|
||||
external: [
|
||||
"bun:test",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"use-sync-external-store"
|
||||
],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm", "cjs"],
|
||||
format: ["esm"],
|
||||
splitting: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
onSuccess: async () => {
|
||||
console.log("--- ui built");
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.logLevel = "silent";
|
||||
options.chunkNames = "chunks/[name]-[hash]";
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -133,7 +116,7 @@ function baseConfig(adapter: string): tsup.Options {
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: [`src/adapter/${adapter}`],
|
||||
entry: [`src/adapter/${adapter}/index.ts`],
|
||||
format: ["esm"],
|
||||
platform: "neutral",
|
||||
outDir: `dist/adapter/${adapter}`,
|
||||
@@ -148,41 +131,29 @@ function baseConfig(adapter: string): tsup.Options {
|
||||
],
|
||||
metafile: true,
|
||||
splitting: false,
|
||||
treeshake: true
|
||||
treeshake: true,
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
await tsup.build(baseConfig("remix"));
|
||||
await tsup.build(baseConfig("bun"));
|
||||
await tsup.build(baseConfig("astro"));
|
||||
await tsup.build(baseConfig("cloudflare"));
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("vite"),
|
||||
platform: "node"
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("cloudflare")
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("nextjs"),
|
||||
format: ["esm", "cjs"],
|
||||
platform: "node"
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("remix"),
|
||||
format: ["esm", "cjs"]
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("bun")
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("node"),
|
||||
platform: "node",
|
||||
format: ["esm", "cjs"]
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("astro"),
|
||||
format: ["esm", "cjs"]
|
||||
platform: "node"
|
||||
});
|
||||
|
||||
+36
-31
@@ -3,37 +3,38 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"scripts": {
|
||||
"build:all": "bun run build && bun run build:cli",
|
||||
"dev": "vite",
|
||||
"test": "ALL_TESTS=1 bun test --bail",
|
||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
||||
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
||||
"build:static": "vite build",
|
||||
"watch": "bun run build.ts --types --watch",
|
||||
"types": "bun tsc --noEmit",
|
||||
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
||||
"build:types": "tsc --emitDeclarationOnly",
|
||||
"build:css": "bun tailwindcss -i src/ui/main.css -o ./dist/static/styles.css",
|
||||
"watch:css": "bun tailwindcss --watch -i src/ui/main.css -o ./dist/styles.css",
|
||||
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
||||
"updater": "bun x npm-check-updates -ui",
|
||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||
"prepublishOnly": "bun run build:all"
|
||||
"prepublishOnly": "bun run test && bun run build:all"
|
||||
},
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^2.0.1",
|
||||
"@libsql/client": "^0.14.0",
|
||||
"@tanstack/react-form": "0.19.2",
|
||||
"@sinclair/typebox": "^0.32.34",
|
||||
"@tanstack/react-form": "0.19.2",
|
||||
"aws4fetch": "^1.0.18",
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"hono": "^4.6.12",
|
||||
"kysely": "^0.27.4",
|
||||
"liquidjs": "^10.15.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"hono": "^4.6.12",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"@cfworker/json-schema": "^2.0.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"aws4fetch": "^1.0.18"
|
||||
"swr": "^2.2.5",
|
||||
"json-schema-form-react": "^0.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.613.0",
|
||||
@@ -54,8 +55,6 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@rjsf/core": "^5.22.2",
|
||||
"@tabler/icons-react": "3.18.0",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"@tanstack/react-query-devtools": "^5.59.16",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
@@ -76,6 +75,7 @@
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsc-alias": "^1.8.10",
|
||||
"tsup": "^8.3.5",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-static-copy": "^2.0.0",
|
||||
@@ -91,80 +91,85 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"types": "./dist/types/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js",
|
||||
"require": "./dist/ui/index.cjs"
|
||||
},
|
||||
"./elements": {
|
||||
"types": "./dist/types/ui/elements/index.d.ts",
|
||||
"import": "./dist/ui/elements/index.js",
|
||||
"require": "./dist/ui/elements/index.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/ui/client/index.d.ts",
|
||||
"types": "./dist/types/ui/client/index.d.ts",
|
||||
"import": "./dist/ui/client/index.js",
|
||||
"require": "./dist/ui/client/index.cjs"
|
||||
},
|
||||
"./data": {
|
||||
"types": "./dist/data/index.d.ts",
|
||||
"types": "./dist/types/data/index.d.ts",
|
||||
"import": "./dist/data/index.js",
|
||||
"require": "./dist/data/index.cjs"
|
||||
},
|
||||
"./core": {
|
||||
"types": "./dist/core/index.d.ts",
|
||||
"types": "./dist/types/core/index.d.ts",
|
||||
"import": "./dist/core/index.js",
|
||||
"require": "./dist/core/index.cjs"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./dist/core/utils/index.d.ts",
|
||||
"types": "./dist/types/core/utils/index.d.ts",
|
||||
"import": "./dist/core/utils/index.js",
|
||||
"require": "./dist/core/utils/index.cjs"
|
||||
},
|
||||
"./cli": {
|
||||
"types": "./dist/cli/index.d.ts",
|
||||
"types": "./dist/types/cli/index.d.ts",
|
||||
"import": "./dist/cli/index.js",
|
||||
"require": "./dist/cli/index.cjs"
|
||||
},
|
||||
"./adapter/cloudflare": {
|
||||
"types": "./dist/adapter/cloudflare/index.d.ts",
|
||||
"types": "./dist/types/adapter/cloudflare/index.d.ts",
|
||||
"import": "./dist/adapter/cloudflare/index.js",
|
||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
||||
},
|
||||
"./adapter/vite": {
|
||||
"types": "./dist/adapter/vite/index.d.ts",
|
||||
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||
"import": "./dist/adapter/vite/index.js",
|
||||
"require": "./dist/adapter/vite/index.cjs"
|
||||
},
|
||||
"./adapter/nextjs": {
|
||||
"types": "./dist/adapter/nextjs/index.d.ts",
|
||||
"types": "./dist/types/adapter/nextjs/index.d.ts",
|
||||
"import": "./dist/adapter/nextjs/index.js",
|
||||
"require": "./dist/adapter/nextjs/index.cjs"
|
||||
},
|
||||
"./adapter/remix": {
|
||||
"types": "./dist/adapter/remix/index.d.ts",
|
||||
"types": "./dist/types/adapter/remix/index.d.ts",
|
||||
"import": "./dist/adapter/remix/index.js",
|
||||
"require": "./dist/adapter/remix/index.cjs"
|
||||
},
|
||||
"./adapter/bun": {
|
||||
"types": "./dist/adapter/bun/index.d.ts",
|
||||
"types": "./dist/types/adapter/bun/index.d.ts",
|
||||
"import": "./dist/adapter/bun/index.js",
|
||||
"require": "./dist/adapter/bun/index.cjs"
|
||||
},
|
||||
"./adapter/node": {
|
||||
"types": "./dist/adapter/node/index.d.ts",
|
||||
"types": "./dist/types/adapter/node/index.d.ts",
|
||||
"import": "./dist/adapter/node/index.js",
|
||||
"require": "./dist/adapter/node/index.cjs"
|
||||
},
|
||||
"./adapter/astro": {
|
||||
"types": "./dist/adapter/astro/index.d.ts",
|
||||
"types": "./dist/types/adapter/astro/index.d.ts",
|
||||
"import": "./dist/adapter/astro/index.js",
|
||||
"require": "./dist/adapter/astro/index.cjs"
|
||||
},
|
||||
"./dist/styles.css": "./dist/ui/main.css",
|
||||
"./dist/manifest.json": "./dist/static/manifest.json"
|
||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
+40
-3
@@ -1,3 +1,4 @@
|
||||
import type { SafeUser } from "auth";
|
||||
import { AuthApi } from "auth/api/AuthApi";
|
||||
import { DataApi } from "data/api/DataApi";
|
||||
import { decode } from "hono/jwt";
|
||||
@@ -5,7 +6,7 @@ import { omit } from "lodash-es";
|
||||
import { MediaApi } from "media/api/MediaApi";
|
||||
import { SystemApi } from "modules/SystemApi";
|
||||
|
||||
export type TApiUser = object;
|
||||
export type TApiUser = SafeUser;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -24,6 +25,12 @@ export type ApiOptions = {
|
||||
localStorage?: boolean;
|
||||
};
|
||||
|
||||
export type AuthState = {
|
||||
token?: string;
|
||||
user?: TApiUser;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
export class Api {
|
||||
private token?: string;
|
||||
private user?: TApiUser;
|
||||
@@ -50,6 +57,10 @@ export class Api {
|
||||
this.buildApis();
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this.options.host;
|
||||
}
|
||||
|
||||
get tokenKey() {
|
||||
return this.options.key ?? "auth";
|
||||
}
|
||||
@@ -85,7 +96,11 @@ export class Api {
|
||||
|
||||
updateToken(token?: string, rebuild?: boolean) {
|
||||
this.token = token;
|
||||
this.user = token ? omit(decode(token).payload as any, ["iat", "iss", "exp"]) : undefined;
|
||||
if (token) {
|
||||
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||
} else {
|
||||
this.user = undefined;
|
||||
}
|
||||
|
||||
if (this.options.localStorage) {
|
||||
const key = this.tokenKey;
|
||||
@@ -105,7 +120,7 @@ export class Api {
|
||||
return this;
|
||||
}
|
||||
|
||||
getAuthState() {
|
||||
getAuthState(): AuthState {
|
||||
return {
|
||||
token: this.token,
|
||||
user: this.user,
|
||||
@@ -113,6 +128,28 @@ export class Api {
|
||||
};
|
||||
}
|
||||
|
||||
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
|
||||
if (force === true || !this.verified) {
|
||||
await this.verifyAuth();
|
||||
}
|
||||
|
||||
return this.getAuthState();
|
||||
}
|
||||
|
||||
async verifyAuth() {
|
||||
try {
|
||||
const res = await this.auth.me();
|
||||
if (!res.ok || !res.body.user) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
this.markAuthVerified(true);
|
||||
} catch (e) {
|
||||
this.markAuthVerified(false);
|
||||
this.updateToken(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
getUser(): TApiUser | null {
|
||||
return this.user || null;
|
||||
}
|
||||
|
||||
+62
-16
@@ -1,4 +1,8 @@
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { auth } from "auth/middlewares";
|
||||
import { config } from "core";
|
||||
import { Event } from "core/events";
|
||||
import { patternMatch } from "core/utils";
|
||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
@@ -10,15 +14,19 @@ import * as SystemPermissions from "modules/permissions";
|
||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||
import { SystemController } from "modules/server/SystemController";
|
||||
|
||||
export type AppPlugin<DB> = (app: App<DB>) => void;
|
||||
export type AppPlugin = (app: App) => Promise<void> | void;
|
||||
|
||||
export class AppConfigUpdatedEvent extends Event<{ app: App }> {
|
||||
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
|
||||
export class AppConfigUpdatedEvent extends AppEvent {
|
||||
static override slug = "app-config-updated";
|
||||
}
|
||||
export class AppBuiltEvent extends Event<{ app: App }> {
|
||||
export class AppBuiltEvent extends AppEvent {
|
||||
static override slug = "app-built";
|
||||
}
|
||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent } as const;
|
||||
export class AppFirstBoot extends AppEvent {
|
||||
static override slug = "app-first-boot";
|
||||
}
|
||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
|
||||
|
||||
export type CreateAppConfig = {
|
||||
connection?:
|
||||
@@ -28,29 +36,48 @@ export type CreateAppConfig = {
|
||||
config: LibSqlCredentials;
|
||||
};
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
plugins?: AppPlugin<any>[];
|
||||
plugins?: AppPlugin[];
|
||||
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
|
||||
};
|
||||
|
||||
export type AppConfig = InitialModuleConfigs;
|
||||
|
||||
export class App<DB = any> {
|
||||
export class App {
|
||||
modules: ModuleManager;
|
||||
static readonly Events = AppEvents;
|
||||
adminController?: AdminController;
|
||||
private trigger_first_boot = false;
|
||||
|
||||
constructor(
|
||||
private connection: Connection,
|
||||
_initialConfig?: InitialModuleConfigs,
|
||||
private plugins: AppPlugin<DB>[] = [],
|
||||
private plugins: AppPlugin[] = [],
|
||||
moduleManagerOptions?: ModuleManagerOptions
|
||||
) {
|
||||
this.modules = new ModuleManager(connection, {
|
||||
...moduleManagerOptions,
|
||||
initial: _initialConfig,
|
||||
onUpdated: async (key, config) => {
|
||||
//console.log("[APP] config updated", key, config);
|
||||
// if the EventManager was disabled, we assume we shouldn't
|
||||
// respond to events, such as "onUpdated".
|
||||
if (!this.emgr.enabled) {
|
||||
console.warn("[APP] config updated, but event manager is disabled, skip.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[APP] config updated", key);
|
||||
await this.build({ sync: true, save: true });
|
||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||
},
|
||||
onFirstBoot: async () => {
|
||||
console.log("[APP] first boot");
|
||||
this.trigger_first_boot = true;
|
||||
},
|
||||
onServerInit: async (server) => {
|
||||
server.use(async (c, next) => {
|
||||
c.set("app", this);
|
||||
await next();
|
||||
});
|
||||
}
|
||||
});
|
||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||
@@ -70,32 +97,46 @@ export class App<DB = any> {
|
||||
//console.log("syncing", syncResult);
|
||||
}
|
||||
|
||||
const { guard, server } = this.modules.ctx();
|
||||
|
||||
// load system controller
|
||||
this.modules.ctx().guard.registerPermissions(Object.values(SystemPermissions));
|
||||
this.modules.server.route("/api/system", new SystemController(this).getController());
|
||||
guard.registerPermissions(Object.values(SystemPermissions));
|
||||
server.route("/api/system", new SystemController(this).getController());
|
||||
|
||||
// load plugins
|
||||
if (this.plugins.length > 0) {
|
||||
this.plugins.forEach((plugin) => plugin(this));
|
||||
await Promise.all(this.plugins.map((plugin) => plugin(this)));
|
||||
}
|
||||
|
||||
//console.log("emitting built", options);
|
||||
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
||||
|
||||
// not found on any not registered api route
|
||||
this.modules.server.all("/api/*", async (c) => c.notFound());
|
||||
server.all("/api/*", async (c) => c.notFound());
|
||||
|
||||
if (options?.save) {
|
||||
await this.modules.save();
|
||||
}
|
||||
|
||||
// first boot is set from ModuleManager when there wasn't a config table
|
||||
if (this.trigger_first_boot) {
|
||||
this.trigger_first_boot = false;
|
||||
await this.emgr.emit(new AppFirstBoot({ app: this }));
|
||||
}
|
||||
}
|
||||
|
||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||
return this.modules.get(module).schema();
|
||||
}
|
||||
|
||||
get server() {
|
||||
return this.modules.server;
|
||||
}
|
||||
|
||||
get em() {
|
||||
return this.modules.ctx().em;
|
||||
}
|
||||
|
||||
get fetch(): any {
|
||||
return this.modules.server.fetch;
|
||||
return this.server.fetch;
|
||||
}
|
||||
|
||||
get module() {
|
||||
@@ -119,7 +160,8 @@ export class App<DB = any> {
|
||||
|
||||
registerAdminController(config?: AdminControllerOptions) {
|
||||
// register admin
|
||||
this.modules.server.route("/", new AdminController(this, config).getController());
|
||||
this.adminController = new AdminController(this, config);
|
||||
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -130,6 +172,10 @@ export class App<DB = any> {
|
||||
static create(config: CreateAppConfig) {
|
||||
return createApp(config);
|
||||
}
|
||||
|
||||
async createUser(p: CreateUserPayload) {
|
||||
return this.module.auth.createUser(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(config: CreateAppConfig = {}) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Api, type ApiOptions, App, type CreateAppConfig } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import { Api, type ApiOptions, type App } from "bknd";
|
||||
|
||||
export type AstroBkndConfig = FrameworkBkndConfig;
|
||||
|
||||
type TAstro = {
|
||||
request: Request;
|
||||
@@ -18,12 +21,10 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
export function serve(config: AstroBkndConfig = {}) {
|
||||
return async (args: TAstro) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
|
||||
await app.build();
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
@@ -1,56 +1,54 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import path from "node:path";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import type { Serve, ServeOptions } from "bun";
|
||||
import type { App } from "bknd";
|
||||
import type { ServeOptions } from "bun";
|
||||
import { config } from "core";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
|
||||
let app: App;
|
||||
export async function createApp(_config: Partial<CreateAppConfig> = {}, distPath?: string) {
|
||||
|
||||
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
|
||||
|
||||
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
|
||||
if (!app) {
|
||||
app = App.create(_config);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
app.modules.server.get(
|
||||
"/*",
|
||||
serveStatic({
|
||||
root
|
||||
})
|
||||
);
|
||||
app.registerAdminController();
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export type BunAdapterOptions = Omit<ServeOptions, "fetch"> &
|
||||
CreateAppConfig & {
|
||||
distPath?: string;
|
||||
};
|
||||
|
||||
export function serve({
|
||||
distPath,
|
||||
connection,
|
||||
initialConfig,
|
||||
plugins,
|
||||
options,
|
||||
port = 1337,
|
||||
port = config.server.default_port,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
...serveOptions
|
||||
}: BunAdapterOptions = {}) {
|
||||
}: BunBkndConfig = {}) {
|
||||
Bun.serve({
|
||||
...serveOptions,
|
||||
port,
|
||||
fetch: async (request: Request) => {
|
||||
const app = await createApp({ connection, initialConfig, plugins, options }, distPath);
|
||||
const app = await createApp({
|
||||
connection,
|
||||
initialConfig,
|
||||
plugins,
|
||||
options,
|
||||
onBuilt,
|
||||
buildConfig,
|
||||
distPath
|
||||
});
|
||||
return app.fetch(request);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import type { CreateAppConfig } from "bknd";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import type { BkndConfig, CfBkndModeCache } from "../index";
|
||||
import type { FrameworkBkndConfig } from "../index";
|
||||
import { getCached } from "./modes/cached";
|
||||
import { getDurable } from "./modes/durable";
|
||||
import { getFresh, getWarm } from "./modes/fresh";
|
||||
|
||||
type Context = {
|
||||
request: Request;
|
||||
env: any;
|
||||
ctx: ExecutionContext;
|
||||
manifest: any;
|
||||
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (env: Env) => {
|
||||
kv?: KVNamespace;
|
||||
dobj?: DurableObjectNamespace;
|
||||
};
|
||||
key?: string;
|
||||
keepAliveSeconds?: number;
|
||||
forceHttps?: boolean;
|
||||
manifest?: string;
|
||||
setAdminHtml?: boolean;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||
export type Context = {
|
||||
request: Request;
|
||||
env: any;
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
|
||||
export function serve(config: CloudflareBkndConfig) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
const manifest = config.manifest;
|
||||
|
||||
if (manifest) {
|
||||
const pathname = url.pathname.slice(1);
|
||||
@@ -26,13 +42,10 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||
hono.all("*", async (c, next) => {
|
||||
const res = await serveStatic({
|
||||
path: `./${pathname}`,
|
||||
manifest,
|
||||
onNotFound: (path) => console.log("not found", path)
|
||||
manifest
|
||||
})(c as any, next);
|
||||
if (res instanceof Response) {
|
||||
const ttl = pathname.startsWith("assets/")
|
||||
? 60 * 60 * 24 * 365 // 1 year
|
||||
: 60 * 5; // 5 minutes
|
||||
const ttl = 60 * 60 * 24 * 365;
|
||||
res.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
||||
return res;
|
||||
}
|
||||
@@ -44,218 +57,23 @@ export function serve(_config: BkndConfig, manifest?: string, html?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
..._config,
|
||||
setAdminHtml: _config.setAdminHtml ?? !!manifest
|
||||
};
|
||||
const context = { request, env, ctx, manifest, html };
|
||||
const mode = config.cloudflare?.mode?.(env);
|
||||
config.setAdminHtml = config.setAdminHtml && !!config.manifest;
|
||||
|
||||
if (!mode) {
|
||||
console.log("serving fresh...");
|
||||
const app = await getFresh(config, context);
|
||||
return app.fetch(request, env);
|
||||
} else if ("cache" in mode) {
|
||||
console.log("serving cached...");
|
||||
const app = await getCached(config as any, context);
|
||||
return app.fetch(request, env);
|
||||
} else if ("durableObject" in mode) {
|
||||
console.log("serving durable...");
|
||||
const context = { request, env, ctx } as Context;
|
||||
const mode = config.mode ?? "warm";
|
||||
|
||||
if (config.onBuilt) {
|
||||
console.log("onBuilt() is not supported with DurableObject mode");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const durable = mode.durableObject;
|
||||
const id = durable.idFromName(mode.key);
|
||||
const stub = durable.get(id) as unknown as DurableBkndApp;
|
||||
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
|
||||
const res = await stub.fire(request, {
|
||||
config: create_config,
|
||||
html,
|
||||
keepAliveSeconds: mode.keepAliveSeconds,
|
||||
setAdminHtml: config.setAdminHtml
|
||||
});
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-TTDO", String(performance.now() - start));
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
switch (mode) {
|
||||
case "fresh":
|
||||
return await getFresh(config, context);
|
||||
case "warm":
|
||||
return await getWarm(config, context);
|
||||
case "cache":
|
||||
return await getCached(config, context);
|
||||
case "durable":
|
||||
return await getDurable(config, context);
|
||||
default:
|
||||
throw new Error(`Unknown mode ${mode}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getFresh(config: BkndConfig, { env, html }: Context) {
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
const app = App.create(create_config);
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
config.onBuilt!(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
await app.build();
|
||||
|
||||
if (config.setAdminHtml) {
|
||||
app.registerAdminController({ html });
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function getCached(
|
||||
config: BkndConfig & { cloudflare: { mode: CfBkndModeCache } },
|
||||
{ env, html, ctx }: Context
|
||||
) {
|
||||
const { cache, key } = config.cloudflare.mode(env) as ReturnType<CfBkndModeCache>;
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
|
||||
const cachedConfig = await cache.get(key);
|
||||
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
||||
|
||||
const app = App.create({ ...create_config, initialConfig });
|
||||
|
||||
async function saveConfig(__config: any) {
|
||||
ctx.waitUntil(cache.put(key, JSON.stringify(__config)));
|
||||
}
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||
await cache.delete(key);
|
||||
return c.json({ message: "Cache cleared" });
|
||||
});
|
||||
app.registerAdminController({ html });
|
||||
|
||||
config.onBuilt!(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppConfigUpdatedEvent,
|
||||
async ({ params: { app } }) => {
|
||||
saveConfig(app.toJSON(true));
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
|
||||
if (config.setAdminHtml) {
|
||||
app.registerAdminController({ html });
|
||||
}
|
||||
|
||||
if (!cachedConfig) {
|
||||
saveConfig(app.toJSON(true));
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export class DurableBkndApp extends DurableObject {
|
||||
protected id = Math.random().toString(36).slice(2);
|
||||
protected app?: App;
|
||||
protected interval?: any;
|
||||
|
||||
async fire(
|
||||
request: Request,
|
||||
options: {
|
||||
config: CreateAppConfig;
|
||||
html?: string;
|
||||
keepAliveSeconds?: number;
|
||||
setAdminHtml?: boolean;
|
||||
}
|
||||
) {
|
||||
let buildtime = 0;
|
||||
if (!this.app) {
|
||||
const start = performance.now();
|
||||
const config = options.config;
|
||||
|
||||
// change protocol to websocket if libsql
|
||||
if (
|
||||
config?.connection &&
|
||||
"type" in config.connection &&
|
||||
config.connection.type === "libsql"
|
||||
) {
|
||||
config.connection.config.protocol = "wss";
|
||||
}
|
||||
|
||||
this.app = App.create(config);
|
||||
this.app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async ({ params: { app } }) => {
|
||||
app.modules.server.get("/__do", async (c) => {
|
||||
// @ts-ignore
|
||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||
return c.json({
|
||||
id: this.id,
|
||||
keepAlive: options?.keepAliveSeconds,
|
||||
colo: context.colo
|
||||
});
|
||||
});
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await this.app.build();
|
||||
|
||||
buildtime = performance.now() - start;
|
||||
}
|
||||
|
||||
if (options?.keepAliveSeconds) {
|
||||
this.keepAlive(options.keepAliveSeconds);
|
||||
}
|
||||
|
||||
console.log("id", this.id);
|
||||
const res = await this.app!.fetch(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-BuildTime", buildtime.toString());
|
||||
headers.set("X-DO-ID", this.id);
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
protected keepAlive(seconds: number) {
|
||||
console.log("keep alive for", seconds);
|
||||
if (this.interval) {
|
||||
console.log("clearing, there is a new");
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
this.interval = setInterval(() => {
|
||||
i += 1;
|
||||
//console.log("keep-alive", i);
|
||||
if (i === seconds) {
|
||||
console.log("cleared");
|
||||
clearInterval(this.interval);
|
||||
|
||||
// ping every 30 seconds
|
||||
} else if (i % 30 === 0) {
|
||||
console.log("ping");
|
||||
this.app?.modules.ctx().connection.ping();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||
export { getCached } from "./modes/cached";
|
||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import { App } from "bknd";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
|
||||
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) {
|
||||
const { kv } = config.bindings?.(env)!;
|
||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
|
||||
const cachedConfig = await kv.get(key);
|
||||
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
|
||||
|
||||
async function saveConfig(__config: any) {
|
||||
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
|
||||
}
|
||||
|
||||
const app = await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
initialConfig,
|
||||
onBuilt: async (app) => {
|
||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||
await kv.delete(key);
|
||||
return c.json({ message: "Cache cleared" });
|
||||
});
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
beforeBuild: async (app) => {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppConfigUpdatedEvent,
|
||||
async ({ params: { app } }) => {
|
||||
saveConfig(app.toJSON(true));
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
await config.beforeBuild?.(app);
|
||||
},
|
||||
adminOptions: { html: config.html }
|
||||
},
|
||||
env
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
saveConfig(app.toJSON(true));
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
|
||||
import type { App, CreateAppConfig } from "bknd";
|
||||
|
||||
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
||||
const { dobj } = config.bindings?.(ctx.env)!;
|
||||
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
|
||||
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
|
||||
console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
|
||||
const id = dobj.idFromName(key);
|
||||
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
||||
|
||||
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app;
|
||||
|
||||
const res = await stub.fire(ctx.request, {
|
||||
config: create_config,
|
||||
html: config.html,
|
||||
keepAliveSeconds: config.keepAliveSeconds,
|
||||
setAdminHtml: config.setAdminHtml
|
||||
});
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-TTDO", String(performance.now() - start));
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
export class DurableBkndApp extends DurableObject {
|
||||
protected id = Math.random().toString(36).slice(2);
|
||||
protected app?: App;
|
||||
protected interval?: any;
|
||||
|
||||
async fire(
|
||||
request: Request,
|
||||
options: {
|
||||
config: CreateAppConfig;
|
||||
html?: string;
|
||||
keepAliveSeconds?: number;
|
||||
setAdminHtml?: boolean;
|
||||
}
|
||||
) {
|
||||
let buildtime = 0;
|
||||
if (!this.app) {
|
||||
const start = performance.now();
|
||||
const config = options.config;
|
||||
|
||||
// change protocol to websocket if libsql
|
||||
if (
|
||||
config?.connection &&
|
||||
"type" in config.connection &&
|
||||
config.connection.type === "libsql"
|
||||
) {
|
||||
config.connection.config.protocol = "wss";
|
||||
}
|
||||
|
||||
this.app = await createRuntimeApp({
|
||||
...config,
|
||||
onBuilt: async (app) => {
|
||||
app.modules.server.get("/__do", async (c) => {
|
||||
// @ts-ignore
|
||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||
return c.json({
|
||||
id: this.id,
|
||||
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
|
||||
colo: context.colo
|
||||
});
|
||||
});
|
||||
|
||||
await this.onBuilt(app);
|
||||
},
|
||||
adminOptions: { html: options.html },
|
||||
beforeBuild: async (app) => {
|
||||
await this.beforeBuild(app);
|
||||
}
|
||||
});
|
||||
|
||||
buildtime = performance.now() - start;
|
||||
}
|
||||
|
||||
if (options?.keepAliveSeconds) {
|
||||
this.keepAlive(options.keepAliveSeconds);
|
||||
}
|
||||
|
||||
console.log("id", this.id);
|
||||
const res = await this.app!.fetch(request);
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-BuildTime", buildtime.toString());
|
||||
headers.set("X-DO-ID", this.id);
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
async onBuilt(app: App) {}
|
||||
async beforeBuild(app: App) {}
|
||||
|
||||
protected keepAlive(seconds: number) {
|
||||
console.log("keep alive for", seconds);
|
||||
if (this.interval) {
|
||||
console.log("clearing, there is a new");
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
this.interval = setInterval(() => {
|
||||
i += 1;
|
||||
//console.log("keep-alive", i);
|
||||
if (i === seconds) {
|
||||
console.log("cleared");
|
||||
clearInterval(this.interval);
|
||||
|
||||
// ping every 30 seconds
|
||||
} else if (i % 30 === 0) {
|
||||
console.log("ping");
|
||||
this.app?.modules.ctx().connection.ping();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
import type { CloudflareBkndConfig, Context } from "../index";
|
||||
|
||||
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) {
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
adminOptions: config.html ? { html: config.html } : undefined
|
||||
},
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
|
||||
const app = await makeApp(config, ctx);
|
||||
return app.fetch(ctx.request);
|
||||
}
|
||||
|
||||
let warm_app: App;
|
||||
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
|
||||
if (!warm_app) {
|
||||
warm_app = await makeApp(config, ctx);
|
||||
}
|
||||
|
||||
return warm_app.fetch(ctx.request);
|
||||
}
|
||||
+98
-32
@@ -1,40 +1,21 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { App, CreateAppConfig } from "bknd";
|
||||
import { App, type CreateAppConfig, registries } from "bknd";
|
||||
import { config as $config } from "core";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
|
||||
export type CfBkndModeCache<Env = any> = (env: Env) => {
|
||||
cache: KVNamespace;
|
||||
key: string;
|
||||
};
|
||||
|
||||
export type CfBkndModeDurableObject<Env = any> = (env: Env) => {
|
||||
durableObject: DurableObjectNamespace;
|
||||
key: string;
|
||||
keepAliveSeconds?: number;
|
||||
};
|
||||
|
||||
export type CloudflareBkndConfig<Env = any> = {
|
||||
mode?: CfBkndModeCache | CfBkndModeDurableObject;
|
||||
forceHttps?: boolean;
|
||||
};
|
||||
|
||||
// @todo: move to App
|
||||
export type BkndConfig<Env = any> = {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
platform?: "node" | "bun";
|
||||
};
|
||||
cloudflare?: CloudflareBkndConfig<Env>;
|
||||
export type BkndConfig<Env = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
buildConfig?: Parameters<App["build"]>[0];
|
||||
};
|
||||
|
||||
export type BkndConfigJson = {
|
||||
app: CreateAppConfig;
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
};
|
||||
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
|
||||
|
||||
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
|
||||
distPath?: string;
|
||||
};
|
||||
|
||||
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||
@@ -60,3 +41,88 @@ export function nodeRequestToRequest(req: IncomingMessage): Request {
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMediaAdapter() {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
}
|
||||
|
||||
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
|
||||
let additionalConfig: CreateAppConfig = {};
|
||||
if ("app" in config && config.app) {
|
||||
if (typeof config.app === "function") {
|
||||
if (!env) {
|
||||
throw new Error("env is required when config.app is a function");
|
||||
}
|
||||
additionalConfig = config.app(env);
|
||||
} else {
|
||||
additionalConfig = config.app;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...config, ...additionalConfig };
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Env = any>(
|
||||
config: FrameworkBkndConfig,
|
||||
env?: Env
|
||||
): Promise<App> {
|
||||
const app = App.create(makeConfig(config, env));
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
}
|
||||
|
||||
await config.beforeBuild?.(app);
|
||||
await app.build(config.buildConfig);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function createRuntimeApp<Env = any>(
|
||||
{
|
||||
serveStatic,
|
||||
registerLocalMedia,
|
||||
adminOptions,
|
||||
...config
|
||||
}: RuntimeBkndConfig & {
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
registerLocalMedia?: boolean;
|
||||
adminOptions?: AdminControllerOptions | false;
|
||||
},
|
||||
env?: Env
|
||||
): Promise<App> {
|
||||
if (registerLocalMedia) {
|
||||
registerLocalMediaAdapter();
|
||||
}
|
||||
|
||||
const app = App.create(makeConfig(config, env));
|
||||
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
if (serveStatic) {
|
||||
const [path, handler] = Array.isArray(serveStatic)
|
||||
? serveStatic
|
||||
: [$config.server.assets_path + "*", serveStatic];
|
||||
app.modules.server.get(path, handler);
|
||||
}
|
||||
|
||||
await config.onBuilt?.(app);
|
||||
if (adminOptions !== false) {
|
||||
app.registerAdminController(adminOptions);
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await config.beforeBuild?.(app);
|
||||
await app.build(config.buildConfig);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Api, App, type CreateAppConfig } from "bknd";
|
||||
import { nodeRequestToRequest } from "../index";
|
||||
import { Api, type App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
|
||||
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig;
|
||||
|
||||
type GetServerSidePropsContext = {
|
||||
req: IncomingMessage;
|
||||
@@ -18,7 +20,6 @@ type GetServerSidePropsContext = {
|
||||
|
||||
export function createApi({ req }: GetServerSidePropsContext) {
|
||||
const request = nodeRequestToRequest(req);
|
||||
//console.log("createApi:request.headers", request.headers);
|
||||
return new Api({
|
||||
host: new URL(request.url).origin,
|
||||
headers: request.headers
|
||||
@@ -43,11 +44,10 @@ function getCleanRequest(req: Request) {
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
export function serve(config: NextjsBkndConfig = {}) {
|
||||
return async (req: Request) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
await app.build();
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
const request = getCleanRequest(req);
|
||||
return app.fetch(request, process.env);
|
||||
|
||||
@@ -1,59 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
|
||||
export type NodeAdapterOptions = CreateAppConfig & {
|
||||
relativeDistPath?: string;
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
listener?: Parameters<typeof honoServe>[1];
|
||||
};
|
||||
|
||||
export function serve({
|
||||
relativeDistPath,
|
||||
port = 1337,
|
||||
hostname,
|
||||
listener,
|
||||
...config
|
||||
}: NodeAdapterOptions = {}) {
|
||||
const root = path.relative(
|
||||
process.cwd(),
|
||||
path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static")
|
||||
);
|
||||
let app: App;
|
||||
|
||||
honoServe(
|
||||
{
|
||||
port,
|
||||
hostname,
|
||||
fetch: async (req: Request) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
app.modules.server.get(
|
||||
"/*",
|
||||
serveStatic({
|
||||
root
|
||||
})
|
||||
);
|
||||
app.registerAdminController();
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
}
|
||||
|
||||
return app.fetch(req);
|
||||
}
|
||||
},
|
||||
(connInfo) => {
|
||||
console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||
listener?.(connInfo);
|
||||
}
|
||||
);
|
||||
}
|
||||
export * from "./node.adapter";
|
||||
export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig
|
||||
} from "../../media/storage/adapters/StorageLocalAdapter";
|
||||
export { registerLocalMediaAdapter } from "../index";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import type { App } from "bknd";
|
||||
import { config as $config } from "core";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
|
||||
export type NodeBkndConfig = RuntimeBkndConfig & {
|
||||
port?: number;
|
||||
hostname?: string;
|
||||
listener?: Parameters<typeof honoServe>[1];
|
||||
/** @deprecated */
|
||||
relativeDistPath?: string;
|
||||
};
|
||||
|
||||
export function serve({
|
||||
distPath,
|
||||
relativeDistPath,
|
||||
port = $config.server.default_port,
|
||||
hostname,
|
||||
listener,
|
||||
...config
|
||||
}: NodeBkndConfig = {}) {
|
||||
const root = path.relative(
|
||||
process.cwd(),
|
||||
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static")
|
||||
);
|
||||
if (relativeDistPath) {
|
||||
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||
}
|
||||
|
||||
let app: App;
|
||||
|
||||
honoServe(
|
||||
{
|
||||
port,
|
||||
hostname,
|
||||
fetch: async (req: Request) => {
|
||||
if (!app) {
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
|
||||
return app.fetch(req);
|
||||
}
|
||||
},
|
||||
(connInfo) => {
|
||||
console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||
listener?.(connInfo);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
|
||||
export type RemixBkndConfig = FrameworkBkndConfig;
|
||||
|
||||
let app: App;
|
||||
export function serve(config: CreateAppConfig) {
|
||||
export function serve(config: RemixBkndConfig = {}) {
|
||||
return async (args: { request: Request }) => {
|
||||
if (!app) {
|
||||
app = App.create(config);
|
||||
await app.build();
|
||||
app = await createFrameworkApp(config);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const devServerConfig = {
|
||||
entry: "./server.ts",
|
||||
exclude: [
|
||||
/.*\.tsx?($|\?)/,
|
||||
/^(?!.*\/__admin).*\.(s?css|less)($|\?)/,
|
||||
// exclude except /api
|
||||
/^(?!.*\/api).*\.(ico|mp4|jpg|jpeg|svg|png|vtt|mp3|js)($|\?)/,
|
||||
/^\/@.+$/,
|
||||
/\/components.*?\.json.*/, // @todo: improve
|
||||
/^\/(public|assets|static)\/.+/,
|
||||
/^\/node_modules\/.*/
|
||||
] as any,
|
||||
injectClientScript: false
|
||||
} as const;
|
||||
@@ -1,50 +1,81 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import type { BkndConfig } from "bknd";
|
||||
import { App } from "bknd";
|
||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
import { devServerConfig } from "./dev-server-config";
|
||||
|
||||
function createApp(config: BkndConfig, env: any) {
|
||||
const create_config = typeof config.app === "function" ? config.app(env) : config.app;
|
||||
return App.create(create_config);
|
||||
}
|
||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "cached" | "fresh";
|
||||
setAdminHtml?: boolean;
|
||||
forceDev?: boolean | { mainPath: string };
|
||||
html?: string;
|
||||
};
|
||||
|
||||
function setAppBuildListener(app: App, config: BkndConfig, html?: string) {
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
async () => {
|
||||
await config.onBuilt?.(app);
|
||||
if (config.setAdminHtml) {
|
||||
app.registerAdminController({ html, forceDev: true });
|
||||
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
||||
}
|
||||
},
|
||||
"sync"
|
||||
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
||||
return html.replace(
|
||||
"</head>",
|
||||
`<script type="module">
|
||||
import RefreshRuntime from "/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
<script type="module" src="/@vite/client"></script>
|
||||
${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
</head>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function serveFresh(config: BkndConfig, _html?: string) {
|
||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
adminOptions:
|
||||
config.setAdminHtml === false
|
||||
? undefined
|
||||
: {
|
||||
html: config.html,
|
||||
forceDev: config.forceDev ?? {
|
||||
mainPath: "/src/main.tsx"
|
||||
}
|
||||
},
|
||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })]
|
||||
},
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const app = createApp(config, env);
|
||||
|
||||
setAppBuildListener(app, config, _html);
|
||||
await app.build();
|
||||
|
||||
const app = await createApp(config, env);
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export async function serveCached(config: BkndConfig, _html?: string) {
|
||||
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
if (!app) {
|
||||
app = createApp(config, env);
|
||||
setAppBuildListener(app, config, _html);
|
||||
await app.build();
|
||||
app = await createApp(config, env);
|
||||
}
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
|
||||
return mode === "fresh" ? serveFresh(config) : serveCached(config);
|
||||
}
|
||||
|
||||
export function devServer(options: DevServerOptions) {
|
||||
return honoViteDevServer({
|
||||
...devServerConfig,
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
+59
-52
@@ -1,21 +1,25 @@
|
||||
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
|
||||
import { Exception } from "core";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { auth } from "auth/middlewares";
|
||||
import { type DB, Exception, type PrimaryFieldType } from "core";
|
||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
||||
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||
import { type FieldSchema, entity, enumm, make, text } from "data/prototype";
|
||||
import { type FieldSchema, em, entity, enumm, make, text } from "data/prototype";
|
||||
import type { Hono } from "hono";
|
||||
import { pick } from "lodash-es";
|
||||
import { Module } from "modules/Module";
|
||||
import { AuthController } from "./api/AuthController";
|
||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
||||
|
||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||
declare global {
|
||||
declare module "core" {
|
||||
interface DB {
|
||||
users: UserFieldSchema;
|
||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
type AuthSchema = Static<typeof authConfigSchema>;
|
||||
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||
|
||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
private _authenticator?: Authenticator;
|
||||
@@ -35,8 +39,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
return to;
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
override async build() {
|
||||
if (!this.config.enabled) {
|
||||
if (!this.enabled) {
|
||||
this.setBuilt();
|
||||
return;
|
||||
}
|
||||
@@ -83,14 +91,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
return this._controller;
|
||||
}
|
||||
|
||||
getMiddleware() {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new AuthController(this).getMiddleware;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return authConfigSchema;
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
return this._authenticator!;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
get em(): EntityManager {
|
||||
return this.ctx.em as any;
|
||||
}
|
||||
|
||||
@@ -110,12 +110,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
): Promise<any> {
|
||||
console.log("***** AppAuth:resolveUser", {
|
||||
/*console.log("***** AppAuth:resolveUser", {
|
||||
action,
|
||||
strategy: strategy.getName(),
|
||||
identifier,
|
||||
profile
|
||||
});
|
||||
});*/
|
||||
if (!this.config.allow_register && action === "register") {
|
||||
throw new Exception("Registration is not allowed", 403);
|
||||
}
|
||||
@@ -136,12 +136,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
}
|
||||
|
||||
private filterUserData(user: any) {
|
||||
console.log(
|
||||
/*console.log(
|
||||
"--filterUserData",
|
||||
user,
|
||||
this.config.jwt.fields,
|
||||
pick(user, this.config.jwt.fields)
|
||||
);
|
||||
);*/
|
||||
return pick(user, this.config.jwt.fields);
|
||||
}
|
||||
|
||||
@@ -160,23 +160,25 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
|
||||
const users = this.getUsersEntity();
|
||||
this.toggleStrategyValueVisibility(true);
|
||||
const result = await this.em.repo(users).findOne({ email: profile.email! });
|
||||
const result = await this.em
|
||||
.repo(users as unknown as "users")
|
||||
.findOne({ email: profile.email! });
|
||||
this.toggleStrategyValueVisibility(false);
|
||||
if (!result.data) {
|
||||
throw new Exception("User not found", 404);
|
||||
}
|
||||
console.log("---login data", result.data, result);
|
||||
//console.log("---login data", result.data, result);
|
||||
|
||||
// compare strategy and identifier
|
||||
console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||
//console.log("strategy comparison", result.data.strategy, strategy.getName());
|
||||
if (result.data.strategy !== strategy.getName()) {
|
||||
console.log("!!! User registered with different strategy");
|
||||
//console.log("!!! User registered with different strategy");
|
||||
throw new Exception("User registered with different strategy");
|
||||
}
|
||||
|
||||
console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||
//console.log("identifier comparison", result.data.strategy_value, identifier);
|
||||
if (result.data.strategy_value !== identifier) {
|
||||
console.log("!!! Invalid credentials");
|
||||
//console.log("!!! Invalid credentials");
|
||||
throw new Exception("Invalid credentials");
|
||||
}
|
||||
|
||||
@@ -197,7 +199,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
throw new Exception("User already exists");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
...profile,
|
||||
strategy: strategy.getName(),
|
||||
strategy_value: identifier
|
||||
@@ -244,46 +246,51 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
||||
};
|
||||
|
||||
registerEntities() {
|
||||
const users = this.getUsersEntity();
|
||||
|
||||
if (!this.em.hasEntity(users.name)) {
|
||||
this.em.addEntity(users);
|
||||
} else {
|
||||
// if exists, check all fields required are there
|
||||
// @todo: add to context: "needs sync" flag
|
||||
const _entity = this.getUsersEntity(true);
|
||||
for (const field of _entity.fields) {
|
||||
const _field = users.field(field.name);
|
||||
if (!_field) {
|
||||
users.addField(field);
|
||||
const users = this.getUsersEntity(true);
|
||||
this.ensureSchema(
|
||||
em(
|
||||
{
|
||||
[users.name as "users"]: users
|
||||
},
|
||||
({ index }, { users }) => {
|
||||
index(users).on(["email"], true).on(["strategy"]).on(["strategy_value"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const indices = [
|
||||
new EntityIndex(users, [users.field("email")!], true),
|
||||
new EntityIndex(users, [users.field("strategy")!]),
|
||||
new EntityIndex(users, [users.field("strategy_value")!])
|
||||
];
|
||||
indices.forEach((index) => {
|
||||
if (!this.em.hasIndex(index)) {
|
||||
this.em.addIndex(index);
|
||||
}
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const roles = Object.keys(this.config.roles ?? {});
|
||||
const field = make("role", enumm({ enum: roles }));
|
||||
this.em.entity(users.name).__experimental_replaceField("role", field);
|
||||
users.__replaceField("role", field);
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
const strategies = Object.keys(this.config.strategies ?? {});
|
||||
const field = make("strategy", enumm({ enum: strategies }));
|
||||
this.em.entity(users.name).__experimental_replaceField("strategy", field);
|
||||
users.__replaceField("strategy", field);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async createUser({ email, password, ...additional }: CreateUserPayload): Promise<DB["users"]> {
|
||||
if (!this.enabled) {
|
||||
throw new Error("Cannot create user, auth not enabled");
|
||||
}
|
||||
|
||||
const strategy = "password";
|
||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||
const strategy_value = await pw.hash(password);
|
||||
const mutator = this.em.mutator(this.config.entity_name as "users");
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
const { data: created } = await mutator.insertOne({
|
||||
...(additional as any),
|
||||
email,
|
||||
strategy,
|
||||
strategy_value
|
||||
});
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
return created;
|
||||
}
|
||||
|
||||
override toJSON(secrets?: boolean): AppAuthSchema {
|
||||
if (!this.config.enabled) {
|
||||
return this.configDefault;
|
||||
|
||||
@@ -15,7 +15,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
|
||||
async loginWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "login"], input);
|
||||
if (res.res.ok && res.body.token) {
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
@@ -23,17 +23,17 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
|
||||
async registerWithPassword(input: any) {
|
||||
const res = await this.post<AuthResponse>(["password", "register"], input);
|
||||
if (res.res.ok && res.body.token) {
|
||||
if (res.ok && res.body.token) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async me() {
|
||||
me() {
|
||||
return this.get<{ user: SafeUser | null }>(["me"]);
|
||||
}
|
||||
|
||||
async strategies() {
|
||||
strategies() {
|
||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,42 +1,18 @@
|
||||
import type { AppAuth } from "auth";
|
||||
import { type ClassController, isDebug } from "core";
|
||||
import { Hono, type MiddlewareHandler } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
|
||||
export class AuthController implements ClassController {
|
||||
constructor(private auth: AppAuth) {}
|
||||
export class AuthController extends Controller {
|
||||
constructor(private auth: AppAuth) {
|
||||
super();
|
||||
}
|
||||
|
||||
get guard() {
|
||||
return this.auth.ctx.guard;
|
||||
}
|
||||
|
||||
getMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
// @todo: ONLY HOTFIX
|
||||
// middlewares are added for all routes are registered. But we need to make sure that
|
||||
// only HTML/JSON routes are adding a cookie to the response. Config updates might
|
||||
// also use an extension "syntax", e.g. /api/system/patch/data/entities.posts
|
||||
// This middleware should be extracted and added by each Controller individually,
|
||||
// but it requires access to the auth secret.
|
||||
// Note: This doesn't mean endpoints aren't protected, just the cookie is not set.
|
||||
const url = new URL(c.req.url);
|
||||
const last = url.pathname.split("/")?.pop();
|
||||
const ext = last?.includes(".") ? last.split(".")?.pop() : undefined;
|
||||
if (
|
||||
!this.auth.authenticator.isJsonRequest(c) &&
|
||||
["GET", "HEAD", "OPTIONS"].includes(c.req.method) &&
|
||||
ext &&
|
||||
["js", "css", "png", "jpg", "jpeg", "svg", "ico"].includes(ext)
|
||||
) {
|
||||
isDebug() && console.log("Skipping auth", { ext }, url.pathname);
|
||||
} else {
|
||||
const user = await this.auth.authenticator.resolveAuthFromRequest(c);
|
||||
this.auth.ctx.guard.setUserContext(user);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
override getController() {
|
||||
const { auth } = this.middlewares;
|
||||
const hono = this.create();
|
||||
const strategies = this.auth.authenticator.getStrategies();
|
||||
|
||||
for (const [name, strategy] of Object.entries(strategies)) {
|
||||
@@ -44,7 +20,7 @@ export class AuthController implements ClassController {
|
||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||
}
|
||||
|
||||
hono.get("/me", async (c) => {
|
||||
hono.get("/me", auth(), async (c) => {
|
||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
||||
return c.json({ user: await this.auth.authenticator.getUser() });
|
||||
}
|
||||
@@ -52,7 +28,7 @@ export class AuthController implements ClassController {
|
||||
return c.json({ user: null }, 403);
|
||||
});
|
||||
|
||||
hono.get("/logout", async (c) => {
|
||||
hono.get("/logout", auth(), async (c) => {
|
||||
await this.auth.authenticator.logout(c);
|
||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -33,6 +33,7 @@ const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
|
||||
const strategiesSchema = Type.Union(Object.values(strategiesSchemaObject));
|
||||
export type AppAuthStrategies = Static<typeof strategiesSchema>;
|
||||
export type AppAuthOAuthStrategy = Static<typeof STRATEGIES.oauth.schema>;
|
||||
export type AppAuthCustomOAuthStrategy = Static<typeof STRATEGIES.custom_oauth.schema>;
|
||||
|
||||
const guardConfigSchema = Type.Object({
|
||||
enabled: Type.Optional(Type.Boolean({ default: false }))
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { Exception } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
type TSchema,
|
||||
Type,
|
||||
parse,
|
||||
randomString,
|
||||
transformObject
|
||||
} from "core/utils";
|
||||
import { type Static, StringEnum, Type, parse, runtimeSupports, transformObject } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
import type { CookieOptions } from "hono/utils/cookie";
|
||||
import { omit } from "lodash-es";
|
||||
import type { ServerEnv } from "modules/Module";
|
||||
|
||||
type Input = any; // workaround
|
||||
export type JWTPayload = Parameters<typeof sign>[0];
|
||||
@@ -67,6 +59,9 @@ export const cookieConfig = Type.Partial(
|
||||
{ default: {}, additionalProperties: false }
|
||||
);
|
||||
|
||||
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
|
||||
// see auth.integration test for further details
|
||||
|
||||
export const jwtConfig = Type.Object(
|
||||
{
|
||||
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
|
||||
@@ -98,7 +93,13 @@ export type AuthUserResolver = (
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
private readonly strategies: Strategies;
|
||||
private readonly config: AuthConfig;
|
||||
private _user: SafeUser | undefined;
|
||||
private _claims:
|
||||
| undefined
|
||||
| (SafeUser & {
|
||||
iat: number;
|
||||
iss?: string;
|
||||
exp?: number;
|
||||
});
|
||||
private readonly userResolver: AuthUserResolver;
|
||||
|
||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
||||
@@ -131,16 +132,18 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
isUserLoggedIn(): boolean {
|
||||
return this._user !== undefined;
|
||||
return this._claims !== undefined;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this._user;
|
||||
getUser(): SafeUser | undefined {
|
||||
if (!this._claims) return;
|
||||
|
||||
const { iat, exp, iss, ...user } = this._claims;
|
||||
return user;
|
||||
}
|
||||
|
||||
// @todo: determine what to do exactly
|
||||
__setUserNull() {
|
||||
this._user = undefined;
|
||||
resetUser() {
|
||||
this._claims = undefined;
|
||||
}
|
||||
|
||||
strategy<
|
||||
@@ -154,6 +157,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
// @todo: add jwt tests
|
||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
||||
const prohibited = ["password"];
|
||||
for (const prop of prohibited) {
|
||||
@@ -200,11 +204,11 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
this._user = omit(payload, ["iat", "exp", "iss"]) as SafeUser;
|
||||
this._claims = payload as any;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this._user = undefined;
|
||||
console.error(e);
|
||||
this.resetUser();
|
||||
//console.error(e);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -220,38 +224,48 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
private async getAuthCookie(c: Context): Promise<string | undefined> {
|
||||
const secret = this.config.jwt.secret;
|
||||
try {
|
||||
const secret = this.config.jwt.secret;
|
||||
const token = await getSignedCookie(c, secret, "auth");
|
||||
if (typeof token !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
} catch (e: any) {
|
||||
if (e instanceof Error) {
|
||||
console.error("[Error:getAuthCookie]", e.message);
|
||||
}
|
||||
|
||||
const token = await getSignedCookie(c, secret, "auth");
|
||||
if (typeof token !== "string") {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
async requestCookieRefresh(c: Context) {
|
||||
if (this.config.cookie.renew) {
|
||||
const token = await this.getAuthCookie(c);
|
||||
if (token) {
|
||||
console.log("renewing cookie", c.req.url);
|
||||
await this.setAuthCookie(c, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async setAuthCookie(c: Context, token: string) {
|
||||
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
|
||||
const secret = this.config.jwt.secret;
|
||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||
}
|
||||
|
||||
private async deleteAuthCookie(c: Context) {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
}
|
||||
|
||||
async logout(c: Context) {
|
||||
const cookie = await this.getAuthCookie(c);
|
||||
if (cookie) {
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
await this.deleteAuthCookie(c);
|
||||
await addFlashMessage(c, "Signed out", "info");
|
||||
}
|
||||
this.resetUser();
|
||||
}
|
||||
|
||||
// @todo: move this to a server helper
|
||||
@@ -260,18 +274,31 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
return c.req.header("Content-Type") === "application/json";
|
||||
}
|
||||
|
||||
private getSuccessPath(c: Context) {
|
||||
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
|
||||
|
||||
// nextjs doesn't support non-fq urls
|
||||
// but env could be proxied (stackblitz), so we shouldn't fq every url
|
||||
if (!runtimeSupports("redirects_non_fq")) {
|
||||
return new URL(c.req.url).origin + p;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data);
|
||||
}
|
||||
|
||||
const successPath = this.config.cookie.pathSuccess ?? "/";
|
||||
const successUrl = new URL(c.req.url).origin + successPath.replace(/\/+$/, "/");
|
||||
const referer = new URL(redirect ?? c.req.header("Referer") ?? successUrl);
|
||||
const successUrl = this.getSuccessPath(c);
|
||||
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
||||
//console.log("auth respond", { redirect, successUrl, successPath });
|
||||
|
||||
if ("token" in data) {
|
||||
await this.setAuthCookie(c, data.token);
|
||||
// can't navigate to "/" – doesn't work on nextjs
|
||||
//console.log("auth success, redirecting to", successUrl);
|
||||
return c.redirect(successUrl);
|
||||
}
|
||||
|
||||
@@ -281,6 +308,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
await addFlashMessage(c, message, "error");
|
||||
//console.log("auth failed, redirecting to", referer);
|
||||
return c.redirect(referer);
|
||||
}
|
||||
|
||||
@@ -296,7 +324,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
|
||||
if (token) {
|
||||
await this.verify(token);
|
||||
return this._user;
|
||||
return this.getUser();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -98,12 +98,16 @@ export class Guard {
|
||||
if (this.user && typeof this.user.role === "string") {
|
||||
const role = this.roles?.find((role) => role.name === this.user?.role);
|
||||
if (role) {
|
||||
debug && console.log("guard: role found", this.user.role);
|
||||
debug && console.log("guard: role found", [this.user.role]);
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
debug && console.log("guard: role not found", this.user, this.user?.role);
|
||||
debug &&
|
||||
console.log("guard: role not found", {
|
||||
user: this.user,
|
||||
role: this.user?.role
|
||||
});
|
||||
return this.getDefaultRole();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Permission } from "core";
|
||||
import { patternMatch } from "core/utils";
|
||||
import type { Context } from "hono";
|
||||
import { createMiddleware } from "hono/factory";
|
||||
import type { ServerEnv } from "modules/Module";
|
||||
|
||||
function getPath(reqOrCtx: Request | Context) {
|
||||
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
|
||||
return new URL(req.url).pathname;
|
||||
}
|
||||
|
||||
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
||||
if (c.get("auth_skip")) return true;
|
||||
|
||||
const req = c.req.raw;
|
||||
if (!skip) return false;
|
||||
|
||||
const path = getPath(req);
|
||||
const result = skip.some((s) => patternMatch(path, s));
|
||||
|
||||
c.set("auth_skip", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const auth = (options?: {
|
||||
skip?: (string | RegExp)[];
|
||||
}) =>
|
||||
createMiddleware<ServerEnv>(async (c, next) => {
|
||||
// make sure to only register once
|
||||
if (c.get("auth_registered")) {
|
||||
throw new Error(`auth middleware already registered for ${getPath(c)}`);
|
||||
}
|
||||
c.set("auth_registered", true);
|
||||
|
||||
const app = c.get("app");
|
||||
const skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
||||
const guard = app?.modules.ctx().guard;
|
||||
const authenticator = app?.module.auth.authenticator;
|
||||
|
||||
if (!skipped) {
|
||||
const resolved = c.get("auth_resolved");
|
||||
if (!resolved) {
|
||||
if (!app.module.auth.enabled) {
|
||||
guard?.setUserContext(undefined);
|
||||
} else {
|
||||
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
|
||||
c.set("auth_resolved", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
if (!skipped) {
|
||||
// renew cookie if applicable
|
||||
authenticator?.requestCookieRefresh(c);
|
||||
}
|
||||
|
||||
// release
|
||||
guard?.setUserContext(undefined);
|
||||
authenticator?.resetUser();
|
||||
c.set("auth_resolved", false);
|
||||
});
|
||||
|
||||
export const permission = (
|
||||
permission: Permission | Permission[],
|
||||
options?: {
|
||||
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
|
||||
}
|
||||
) =>
|
||||
// @ts-ignore
|
||||
createMiddleware<ServerEnv>(async (c, next) => {
|
||||
const app = c.get("app");
|
||||
//console.log("skip?", c.get("auth_skip"));
|
||||
|
||||
// in tests, app is not defined
|
||||
if (!c.get("auth_registered") || !app) {
|
||||
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||
if (app?.module.auth.enabled) {
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
console.warn(msg);
|
||||
}
|
||||
} else if (!c.get("auth_skip")) {
|
||||
const guard = app.modules.ctx().guard;
|
||||
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||
|
||||
if (options?.onGranted || options?.onDenied) {
|
||||
let returned: undefined | void | Response;
|
||||
if (permissions.every((p) => guard.granted(p))) {
|
||||
returned = await options?.onGranted?.(c);
|
||||
} else {
|
||||
returned = await options?.onDenied?.(c);
|
||||
}
|
||||
if (returned instanceof Response) {
|
||||
return returned;
|
||||
}
|
||||
} else {
|
||||
permissions.some((p) => guard.throwUnlessGranted(p));
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import type { Config } from "@libsql/client/node";
|
||||
import { config } from "core";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import open from "open";
|
||||
import { fileExists, getRelativeDistPath } from "../../utils/sys";
|
||||
@@ -26,7 +27,7 @@ export async function serveStatic(server: Platform): Promise<MiddlewareHandler>
|
||||
}
|
||||
|
||||
export async function attachServeStatic(app: any, platform: Platform) {
|
||||
app.module.server.client.get("/*", await serveStatic(platform));
|
||||
app.module.server.client.get(config.server.assets_path + "*", await serveStatic(platform));
|
||||
}
|
||||
|
||||
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Config } from "@libsql/client/node";
|
||||
import { App, type CreateAppConfig } from "App";
|
||||
import type { BkndConfig } from "adapter";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { StorageLocalAdapter } from "adapter/node";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config } from "core";
|
||||
import { registries } from "modules/registries";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type Platform,
|
||||
@@ -19,7 +21,7 @@ export const run: CliCommand = (program) => {
|
||||
.addOption(
|
||||
new Option("-p, --port <port>", "port to run on")
|
||||
.env("PORT")
|
||||
.default(1337)
|
||||
.default(config.server.default_port)
|
||||
.argParser((v) => Number.parseInt(v))
|
||||
)
|
||||
.addOption(new Option("-c, --config <config>", "config file"))
|
||||
@@ -37,6 +39,12 @@ export const run: CliCommand = (program) => {
|
||||
.action(action);
|
||||
};
|
||||
|
||||
// automatically register local adapter
|
||||
const local = StorageLocalAdapter.prototype.getName();
|
||||
if (!registries.media.has(local)) {
|
||||
registries.media.register(local, StorageLocalAdapter);
|
||||
}
|
||||
|
||||
type MakeAppConfig = {
|
||||
connection?: CreateAppConfig["connection"];
|
||||
server?: { platform?: Platform };
|
||||
@@ -47,8 +55,8 @@ type MakeAppConfig = {
|
||||
async function makeApp(config: MakeAppConfig) {
|
||||
const app = App.create({ connection: config.connection });
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await attachServeStatic(app, config.server?.platform ?? "node");
|
||||
app.registerAdminController();
|
||||
@@ -64,24 +72,23 @@ async function makeApp(config: MakeAppConfig) {
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function makeConfigApp(config: BkndConfig, platform?: Platform) {
|
||||
export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) {
|
||||
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
|
||||
const app = App.create(appConfig);
|
||||
|
||||
app.emgr.on(
|
||||
"app-built",
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await attachServeStatic(app, platform ?? "node");
|
||||
app.registerAdminController();
|
||||
|
||||
if (config.onBuilt) {
|
||||
await config.onBuilt(app);
|
||||
}
|
||||
await config.onBuilt?.(app);
|
||||
},
|
||||
"sync"
|
||||
);
|
||||
|
||||
await app.build();
|
||||
await config.beforeBuild?.(app);
|
||||
await app.build(config.buildConfig);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -102,7 +109,7 @@ async function action(options: {
|
||||
app = await makeApp({ connection, server: { platform: options.server } });
|
||||
} else {
|
||||
console.log("Using config from:", configFilePath);
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||
app = await makeConfigApp(config, options.server);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { password as $password, text as $text } from "@clack/prompts";
|
||||
import type { App } from "App";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import type { App, BkndConfig } from "bknd";
|
||||
import { makeConfigApp } from "cli/commands/run";
|
||||
import { getConfigPath } from "cli/commands/run/platform";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Argument } from "commander";
|
||||
|
||||
export const user: CliCommand = (program) => {
|
||||
@@ -21,7 +21,7 @@ async function action(action: "create" | "update", options: any) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as BkndConfig;
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||
const app = await makeConfigApp(config, options.server);
|
||||
|
||||
switch (action) {
|
||||
@@ -35,9 +35,11 @@ async function action(action: "create" | "update", options: any) {
|
||||
}
|
||||
|
||||
async function create(app: App, options: any) {
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const users_entity = config.entity_name;
|
||||
|
||||
if (!strategy) {
|
||||
throw new Error("Password strategy not configured");
|
||||
}
|
||||
|
||||
const email = await $text({
|
||||
message: "Enter email",
|
||||
@@ -65,16 +67,11 @@ async function create(app: App, options: any) {
|
||||
}
|
||||
|
||||
try {
|
||||
const mutator = app.modules.ctx().em.mutator(users_entity);
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
const res = await mutator.insertOne({
|
||||
const created = await app.createUser({
|
||||
email,
|
||||
strategy: "password",
|
||||
strategy_value: await strategy.hash(password as string)
|
||||
});
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
|
||||
console.log("Created:", res.data);
|
||||
password: await strategy.hash(password as string)
|
||||
})
|
||||
console.log("Created:", created);
|
||||
} catch (e) {
|
||||
console.error("Error", e);
|
||||
}
|
||||
@@ -83,7 +80,7 @@ async function create(app: App, options: any) {
|
||||
async function update(app: App, options: any) {
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
const users_entity = config.entity_name;
|
||||
const users_entity = config.entity_name as "users";
|
||||
const em = app.modules.ctx().em;
|
||||
|
||||
const email = (await $text({
|
||||
@@ -141,4 +138,4 @@ async function update(app: App, options: any) {
|
||||
} catch (e) {
|
||||
console.error("Error", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -1,3 +1,14 @@
|
||||
import type { CreateAppConfig } from "App";
|
||||
import type { FrameworkBkndConfig } from "adapter";
|
||||
import type { Command } from "commander";
|
||||
|
||||
export type CliCommand = (program: Command) => void;
|
||||
|
||||
export type CliBkndConfig<Env = any> = FrameworkBkndConfig & {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
setAdminHtml?: boolean;
|
||||
server?: {
|
||||
port?: number;
|
||||
platform?: "node" | "bun";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,15 @@ import type { Generated } from "kysely";
|
||||
|
||||
export type PrimaryFieldType = number | Generated<number>;
|
||||
|
||||
// biome-ignore lint/suspicious/noEmptyInterface: <explanation>
|
||||
export interface DB {}
|
||||
|
||||
export const config = {
|
||||
server: {
|
||||
default_port: 1337,
|
||||
// resetted to root for now, bc bundling with vite
|
||||
assets_path: "/"
|
||||
},
|
||||
data: {
|
||||
default_primary_field: "id"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export class Exception extends Error {
|
||||
code = 400;
|
||||
override name = "Exception";
|
||||
protected _context = undefined;
|
||||
|
||||
constructor(message: string, code?: number) {
|
||||
super(message);
|
||||
@@ -9,11 +10,16 @@ export class Exception extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
context(context: any) {
|
||||
this._context = context;
|
||||
return this;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.message,
|
||||
type: this.name
|
||||
//message: this.message
|
||||
type: this.name,
|
||||
context: this._context
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export class EventManager<
|
||||
> {
|
||||
protected events: EventClass[] = [];
|
||||
protected listeners: EventListener[] = [];
|
||||
enabled: boolean = true;
|
||||
|
||||
constructor(events?: RegisteredEvents, listeners?: EventListener[]) {
|
||||
if (events) {
|
||||
@@ -28,6 +29,16 @@ export class EventManager<
|
||||
}
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
clearEvents() {
|
||||
this.events = [];
|
||||
return this;
|
||||
@@ -39,6 +50,10 @@ export class EventManager<
|
||||
return this;
|
||||
}
|
||||
|
||||
getListeners(): EventListener[] {
|
||||
return [...this.listeners];
|
||||
}
|
||||
|
||||
get Events(): { [K in keyof RegisteredEvents]: RegisteredEvents[K] } {
|
||||
// proxy class to access events
|
||||
return new Proxy(this, {
|
||||
@@ -133,6 +148,11 @@ export class EventManager<
|
||||
async emit(event: Event) {
|
||||
// @ts-expect-error slug is static
|
||||
const slug = event.constructor.slug;
|
||||
if (!this.enabled) {
|
||||
console.log("EventManager disabled, not emitting", slug);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.eventExists(event)) {
|
||||
throw new Error(`Event "${slug}" not registered`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Hono, MiddlewareHandler } from "hono";
|
||||
export { tbValidator } from "./server/lib/tbValidator";
|
||||
export { Exception, BkndError } from "./errors";
|
||||
export { isDebug } from "./env";
|
||||
export { type PrimaryFieldType, config } from "./config";
|
||||
export { type PrimaryFieldType, config, type DB } from "./config";
|
||||
export { AwsClient } from "./clients/aws/AwsClient";
|
||||
export {
|
||||
SimpleRenderer,
|
||||
|
||||
@@ -69,7 +69,8 @@ export class SchemaObject<Schema extends TObject> {
|
||||
forceParse: true,
|
||||
skipMark: this.isForceParse()
|
||||
});
|
||||
const updatedConfig = noEmit ? valid : await this.onBeforeUpdate(this._config, valid);
|
||||
// regardless of "noEmit" – this should always be triggered
|
||||
const updatedConfig = await this.onBeforeUpdate(this._config, valid);
|
||||
|
||||
this._value = updatedConfig;
|
||||
this._config = Object.freeze(updatedConfig);
|
||||
|
||||
@@ -1,29 +1,50 @@
|
||||
export type Constructor<T> = new (...args: any[]) => T;
|
||||
export class Registry<Item, Items extends Record<string, object> = Record<string, object>> {
|
||||
|
||||
export type RegisterFn<Item> = (unknown: any) => Item;
|
||||
|
||||
export class Registry<
|
||||
Item,
|
||||
Items extends Record<string, Item> = Record<string, Item>,
|
||||
Fn extends RegisterFn<Item> = RegisterFn<Item>
|
||||
> {
|
||||
private is_set: boolean = false;
|
||||
private items: Items = {} as Items;
|
||||
|
||||
set<Actual extends Record<string, object>>(items: Actual) {
|
||||
constructor(private registerFn?: Fn) {}
|
||||
|
||||
set<Actual extends Record<string, Item>>(items: Actual) {
|
||||
if (this.is_set) {
|
||||
throw new Error("Registry is already set");
|
||||
}
|
||||
// @ts-ignore
|
||||
this.items = items;
|
||||
this.items = items as unknown as Items;
|
||||
this.is_set = true;
|
||||
|
||||
return this as unknown as Registry<Item, Actual>;
|
||||
return this as unknown as Registry<Item, Actual, Fn>;
|
||||
}
|
||||
|
||||
add(name: string, item: Item) {
|
||||
// @ts-ignore
|
||||
this.items[name] = item;
|
||||
this.items[name as keyof Items] = item as Items[keyof Items];
|
||||
return this;
|
||||
}
|
||||
|
||||
register(name: string, specific: Parameters<Fn>[0]) {
|
||||
if (this.registerFn) {
|
||||
const item = this.registerFn(specific);
|
||||
this.items[name as keyof Items] = item as Items[keyof Items];
|
||||
return this;
|
||||
}
|
||||
|
||||
return this.add(name, specific);
|
||||
}
|
||||
|
||||
get<Name extends keyof Items>(name: Name): Items[Name] {
|
||||
return this.items[name];
|
||||
}
|
||||
|
||||
has(name: keyof Items): boolean {
|
||||
return name in this.items;
|
||||
}
|
||||
|
||||
all() {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ import { setCookie } from "hono/cookie";
|
||||
const flash_key = "__bknd_flash";
|
||||
export type FlashMessageType = "error" | "warning" | "success" | "info";
|
||||
|
||||
export async function addFlashMessage(
|
||||
c: Context,
|
||||
message: string,
|
||||
type: FlashMessageType = "info"
|
||||
) {
|
||||
setCookie(c, flash_key, JSON.stringify({ type, message }), {
|
||||
path: "/"
|
||||
});
|
||||
export function addFlashMessage(c: Context, message: string, type: FlashMessageType = "info") {
|
||||
if (c.req.header("Accept")?.includes("text/html")) {
|
||||
setCookie(c, flash_key, JSON.stringify({ type, message }), {
|
||||
path: "/"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getCookieValue(name) {
|
||||
|
||||
@@ -20,11 +20,16 @@ export class DebugLogger {
|
||||
return this;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.last = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (!this._enabled) return this;
|
||||
|
||||
const now = performance.now();
|
||||
const time = Number.parseInt(String(now - this.last));
|
||||
const time = this.last === 0 ? 0 : Number.parseInt(String(now - this.last));
|
||||
const indents = " ".repeat(this._context.length);
|
||||
const context =
|
||||
this._context.length > 0 ? `[${this._context[this._context.length - 1]}]` : "";
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from "./crypto";
|
||||
export * from "./uuid";
|
||||
export { FromSchema } from "./typebox/from-schema";
|
||||
export * from "./test";
|
||||
export * from "./runtime";
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
||||
|
||||
/**
|
||||
* Adds additional checks for nextjs
|
||||
*/
|
||||
export function getRuntimeKey(): string {
|
||||
const global = globalThis as any;
|
||||
|
||||
// Detect Next.js server-side runtime
|
||||
if (global?.process?.env?.NEXT_RUNTIME === "nodejs") {
|
||||
return "nextjs";
|
||||
}
|
||||
|
||||
// Detect Next.js edge runtime
|
||||
if (global?.process?.env?.NEXT_RUNTIME === "edge") {
|
||||
return "nextjs-edge";
|
||||
}
|
||||
|
||||
// Detect Next.js client-side runtime
|
||||
// @ts-ignore
|
||||
if (typeof window !== "undefined" && window.__NEXT_DATA__) {
|
||||
return "nextjs-client";
|
||||
}
|
||||
|
||||
return honoGetRuntimeKey();
|
||||
}
|
||||
|
||||
const features = {
|
||||
// supports the redirect of not full qualified addresses
|
||||
// not supported in nextjs
|
||||
redirects_non_fq: true
|
||||
};
|
||||
|
||||
export function runtimeSupports(feature: keyof typeof features) {
|
||||
const runtime = getRuntimeKey();
|
||||
if (runtime.startsWith("nextjs")) {
|
||||
features.redirects_non_fq = false;
|
||||
}
|
||||
|
||||
return features[feature];
|
||||
}
|
||||
@@ -104,3 +104,14 @@ export function replaceSimplePlaceholders(str: string, vars: Record<string, any>
|
||||
return key in vars ? vars[key] : match;
|
||||
});
|
||||
}
|
||||
|
||||
export function patternMatch(target: string, pattern: RegExp | string): boolean {
|
||||
if (pattern instanceof RegExp) {
|
||||
return pattern.test(target);
|
||||
} else if (typeof pattern === "string" && pattern.startsWith("/")) {
|
||||
return new RegExp(pattern).test(target);
|
||||
} else if (typeof pattern === "string") {
|
||||
return target.startsWith(pattern);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,36 @@ const _oldConsoles = {
|
||||
error: console.error
|
||||
};
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log"]) {
|
||||
export async function withDisabledConsole<R>(
|
||||
fn: () => Promise<R>,
|
||||
severities: ConsoleSeverity[] = ["log", "warn", "error"]
|
||||
): Promise<R> {
|
||||
const _oldConsoles = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error
|
||||
};
|
||||
disableConsoleLog(severities);
|
||||
const enable = () => {
|
||||
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
|
||||
console[severity as ConsoleSeverity] = fn;
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await fn();
|
||||
enable();
|
||||
return result;
|
||||
} catch (e) {
|
||||
enable();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
||||
severities.forEach((severity) => {
|
||||
console[severity] = () => null;
|
||||
});
|
||||
return enableConsoleLog;
|
||||
}
|
||||
|
||||
export function enableConsoleLog() {
|
||||
|
||||
+13
-45
@@ -1,52 +1,20 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { DataPermissions, Entity, EntityIndex, type EntityManager, type Field } from "data";
|
||||
import {
|
||||
DataPermissions,
|
||||
type Entity,
|
||||
EntityIndex,
|
||||
type EntityManager,
|
||||
constructEntity,
|
||||
constructRelation
|
||||
} from "data";
|
||||
import { Module } from "modules/Module";
|
||||
import { DataController } from "./api/DataController";
|
||||
import {
|
||||
type AppDataConfig,
|
||||
FIELDS,
|
||||
RELATIONS,
|
||||
type TAppDataEntity,
|
||||
type TAppDataRelation,
|
||||
dataConfigSchema
|
||||
} from "./data-schema";
|
||||
|
||||
export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
static constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
const { type } = fieldConfig;
|
||||
if (!(type in FIELDS)) {
|
||||
throw new Error(`Field type "${type}" not found`);
|
||||
}
|
||||
|
||||
const { field } = FIELDS[type as any];
|
||||
const returnal = new field(name, fieldConfig.config) as Field;
|
||||
return returnal;
|
||||
});
|
||||
|
||||
// @todo: entity must be migrated to typebox
|
||||
return new Entity(
|
||||
name,
|
||||
Object.values(fields),
|
||||
entityConfig.config as any,
|
||||
entityConfig.type as any
|
||||
);
|
||||
}
|
||||
|
||||
static constructRelation(
|
||||
relationConfig: TAppDataRelation,
|
||||
resolver: (name: Entity | string) => Entity
|
||||
) {
|
||||
return new RELATIONS[relationConfig.type].cls(
|
||||
resolver(relationConfig.source),
|
||||
resolver(relationConfig.target),
|
||||
relationConfig.config
|
||||
);
|
||||
}
|
||||
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
||||
|
||||
export class AppData extends Module<typeof dataConfigSchema> {
|
||||
override async build() {
|
||||
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
|
||||
return AppData.constructEntity(name, entityConfig);
|
||||
return constructEntity(name, entityConfig);
|
||||
});
|
||||
|
||||
const _entity = (_e: Entity | string): Entity => {
|
||||
@@ -57,7 +25,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
};
|
||||
|
||||
const relations = transformObject(this.config.relations ?? {}, (relation) =>
|
||||
AppData.constructRelation(relation, _entity)
|
||||
constructRelation(relation, _entity)
|
||||
);
|
||||
|
||||
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
|
||||
@@ -91,7 +59,7 @@ export class AppData<DB> extends Module<typeof dataConfigSchema> {
|
||||
return dataConfigSchema;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
get em(): EntityManager {
|
||||
this.throwIfNotBuilt();
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
+38
-25
@@ -1,3 +1,4 @@
|
||||
import type { DB } from "core";
|
||||
import type { EntityData, RepoQuery, RepositoryResponse } from "data";
|
||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||
|
||||
@@ -15,48 +16,60 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
};
|
||||
}
|
||||
|
||||
async readOne(
|
||||
entity: string,
|
||||
readOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
query: Partial<Omit<RepoQuery, "where" | "limit" | "offset">> = {}
|
||||
) {
|
||||
return this.get<RepositoryResponse<EntityData>>([entity, id], query);
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
|
||||
}
|
||||
|
||||
async readMany(entity: string, query: Partial<RepoQuery> = {}) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async readManyByReference(
|
||||
entity: string,
|
||||
id: PrimaryFieldType,
|
||||
reference: string,
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
query: Partial<RepoQuery> = {}
|
||||
) {
|
||||
return this.get<Pick<RepositoryResponse, "meta" | "data">>(
|
||||
[entity, id, reference],
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async createOne(entity: string, input: EntityData) {
|
||||
return this.post<RepositoryResponse<EntityData>>([entity], input);
|
||||
readManyByReference<
|
||||
E extends keyof DB | string,
|
||||
R extends keyof DB | string,
|
||||
Data = R extends keyof DB ? DB[R] : EntityData
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: Partial<RepoQuery> = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
|
||||
async updateOne(entity: string, id: PrimaryFieldType, input: EntityData) {
|
||||
return this.patch<RepositoryResponse<EntityData>>([entity, id], input);
|
||||
createOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
input: Omit<Data, "id">
|
||||
) {
|
||||
return this.post<RepositoryResponse<Data>>([entity as any], input);
|
||||
}
|
||||
|
||||
async deleteOne(entity: string, id: PrimaryFieldType) {
|
||||
return this.delete<RepositoryResponse<EntityData>>([entity, id]);
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType,
|
||||
input: Partial<Omit<Data, "id">>
|
||||
) {
|
||||
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
|
||||
}
|
||||
|
||||
async count(entity: string, where: RepoQuery["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: string; count: number }>>(
|
||||
[entity, "fn", "count"],
|
||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType
|
||||
) {
|
||||
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
|
||||
}
|
||||
|
||||
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
|
||||
[entity as any, "fn", "count"],
|
||||
where
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,26 @@
|
||||
import { type ClassController, isDebug, tbValidator as tb } from "core";
|
||||
import { Type, objectCleanEmpty, objectTransform } from "core/utils";
|
||||
import { isDebug, tbValidator as tb } from "core";
|
||||
import { StringEnum, Type } from "core/utils";
|
||||
import {
|
||||
DataPermissions,
|
||||
type EntityData,
|
||||
type EntityManager,
|
||||
FieldClassMap,
|
||||
type MutatorResponse,
|
||||
PrimaryField,
|
||||
type RepoQuery,
|
||||
type RepositoryResponse,
|
||||
TextField,
|
||||
querySchema
|
||||
} from "data";
|
||||
import { Hono } from "hono";
|
||||
import type { Handler } from "hono/types";
|
||||
import type { ModuleBuildContext } from "modules";
|
||||
import { Controller } from "modules/Controller";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { type AppDataConfig, FIELDS } from "../data-schema";
|
||||
import type { AppDataConfig } from "../data-schema";
|
||||
|
||||
export class DataController implements ClassController {
|
||||
export class DataController extends Controller {
|
||||
constructor(
|
||||
private readonly ctx: ModuleBuildContext,
|
||||
private readonly config: AppDataConfig
|
||||
) {
|
||||
/*console.log(
|
||||
"data controller",
|
||||
this.em.entities.map((e) => e.name)
|
||||
);*/
|
||||
super();
|
||||
}
|
||||
|
||||
get em(): EntityManager<any> {
|
||||
@@ -74,8 +68,10 @@ export class DataController implements ClassController {
|
||||
}
|
||||
}
|
||||
|
||||
getController(): Hono<any> {
|
||||
const hono = new Hono();
|
||||
override getController() {
|
||||
const { permission, auth } = this.middlewares;
|
||||
const hono = this.create().use(auth());
|
||||
|
||||
const definedEntities = this.em.entities.map((e) => e.name);
|
||||
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
|
||||
.Decode(Number.parseInt)
|
||||
@@ -89,10 +85,7 @@ export class DataController implements ClassController {
|
||||
return func;
|
||||
}
|
||||
|
||||
hono.use("*", async (c, next) => {
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.accessApi);
|
||||
await next();
|
||||
});
|
||||
hono.use("*", permission(SystemPermissions.accessApi));
|
||||
|
||||
// info
|
||||
hono.get(
|
||||
@@ -104,9 +97,7 @@ export class DataController implements ClassController {
|
||||
);
|
||||
|
||||
// sync endpoint
|
||||
hono.get("/sync", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.databaseSync);
|
||||
|
||||
hono.get("/sync", permission(DataPermissions.databaseSync), async (c) => {
|
||||
const force = c.req.query("force") === "1";
|
||||
const drop = c.req.query("drop") === "1";
|
||||
//console.log("force", force);
|
||||
@@ -126,10 +117,9 @@ export class DataController implements ClassController {
|
||||
// fn: count
|
||||
.post(
|
||||
"/:entity/fn/count",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -143,10 +133,9 @@ export class DataController implements ClassController {
|
||||
// fn: exists
|
||||
.post(
|
||||
"/:entity/fn/exists",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.valid("param");
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -163,15 +152,13 @@ export class DataController implements ClassController {
|
||||
*/
|
||||
hono
|
||||
// read entity schema
|
||||
.get("/schema.json", async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
const url = new URL(c.req.url);
|
||||
const $id = `${url.origin}${this.config.basepath}/schema.json`;
|
||||
.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
||||
const $id = `${this.config.basepath}/schema.json`;
|
||||
const schemas = Object.fromEntries(
|
||||
this.em.entities.map((e) => [
|
||||
e.name,
|
||||
{
|
||||
$ref: `schemas/${e.name}`
|
||||
$ref: `${this.config.basepath}/schemas/${e.name}`
|
||||
}
|
||||
])
|
||||
);
|
||||
@@ -183,22 +170,27 @@ export class DataController implements ClassController {
|
||||
})
|
||||
// read schema
|
||||
.get(
|
||||
"/schemas/:entity",
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
"/schemas/:entity/:context?",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
entity: Type.String(),
|
||||
context: Type.Optional(StringEnum(["create", "update"]))
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
const { entity, context } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
const schema = _entity.toSchema();
|
||||
const schema = _entity.toSchema({ context } as any);
|
||||
const url = new URL(c.req.url);
|
||||
const base = `${url.origin}${this.config.basepath}`;
|
||||
const $id = `${base}/schemas/${entity}`;
|
||||
const $id = `${this.config.basepath}/schemas/${entity}`;
|
||||
return c.json({
|
||||
$schema: `${base}/schema.json`,
|
||||
$id,
|
||||
@@ -211,11 +203,10 @@ export class DataController implements ClassController {
|
||||
// read many
|
||||
.get(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
@@ -233,6 +224,7 @@ export class DataController implements ClassController {
|
||||
// read one
|
||||
.get(
|
||||
"/:entity/:id",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
@@ -241,11 +233,7 @@ export class DataController implements ClassController {
|
||||
})
|
||||
),
|
||||
tb("query", querySchema),
|
||||
/*zValidator("param", z.object({ entity: z.string(), id: z.coerce.number() })),
|
||||
zValidator("query", repoQuerySchema),*/
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -259,6 +247,7 @@ export class DataController implements ClassController {
|
||||
// read many by reference
|
||||
.get(
|
||||
"/:entity/:id/:reference",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb(
|
||||
"param",
|
||||
Type.Object({
|
||||
@@ -269,8 +258,6 @@ export class DataController implements ClassController {
|
||||
),
|
||||
tb("query", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity, id, reference } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -287,11 +274,10 @@ export class DataController implements ClassController {
|
||||
// func query
|
||||
.post(
|
||||
"/:entity/query",
|
||||
permission(DataPermissions.entityRead),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityRead);
|
||||
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -309,25 +295,27 @@ export class DataController implements ClassController {
|
||||
*/
|
||||
// insert one
|
||||
hono
|
||||
.post("/:entity", tb("param", Type.Object({ entity: Type.String() })), async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityCreate);
|
||||
.post(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityCreate),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
async (c) => {
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const body = (await c.req.json()) as EntityData;
|
||||
const result = await this.em.mutator(entity).insertOne(body);
|
||||
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
return c.json(this.mutatorResult(result), 201);
|
||||
}
|
||||
const body = (await c.req.json()) as EntityData;
|
||||
const result = await this.em.mutator(entity).insertOne(body);
|
||||
|
||||
return c.json(this.mutatorResult(result), 201);
|
||||
})
|
||||
)
|
||||
// update one
|
||||
.patch(
|
||||
"/:entity/:id",
|
||||
permission(DataPermissions.entityUpdate),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityUpdate);
|
||||
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -341,6 +329,8 @@ export class DataController implements ClassController {
|
||||
// delete one
|
||||
.delete(
|
||||
"/:entity/:id",
|
||||
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
@@ -358,11 +348,10 @@ export class DataController implements ClassController {
|
||||
// delete many
|
||||
.delete(
|
||||
"/:entity",
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Connection } from "./Connection";
|
||||
|
||||
export class DummyConnection extends Connection {
|
||||
constructor() {
|
||||
super(undefined as any);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export class Entity<
|
||||
return this.fields.find((field) => field.name === name);
|
||||
}
|
||||
|
||||
__experimental_replaceField(name: string, field: Field) {
|
||||
__replaceField(name: string, field: Field) {
|
||||
const index = this.fields.findIndex((f) => f.name === name);
|
||||
if (index === -1) {
|
||||
throw new Error(`Field "${name}" not found on entity "${this.name}"`);
|
||||
@@ -158,7 +158,7 @@ export class Entity<
|
||||
}
|
||||
|
||||
get label(): string {
|
||||
return snakeToPascalWithSpaces(this.config.name ?? this.name);
|
||||
return this.config.name ?? snakeToPascalWithSpaces(this.name);
|
||||
}
|
||||
|
||||
field(name: string): Field | undefined {
|
||||
@@ -210,20 +210,34 @@ export class Entity<
|
||||
return true;
|
||||
}
|
||||
|
||||
toSchema(clean?: boolean): object {
|
||||
const fields = Object.fromEntries(this.fields.map((field) => [field.name, field]));
|
||||
toSchema(options?: { clean: boolean; context?: "create" | "update" }): object {
|
||||
let fields: Field[];
|
||||
switch (options?.context) {
|
||||
case "create":
|
||||
case "update":
|
||||
fields = this.getFillableFields(options.context);
|
||||
break;
|
||||
default:
|
||||
fields = this.getFields(true);
|
||||
}
|
||||
|
||||
const _fields = Object.fromEntries(fields.map((field) => [field.name, field]));
|
||||
const schema = Type.Object(
|
||||
transformObject(fields, (field) => ({
|
||||
title: field.config.label,
|
||||
$comment: field.config.description,
|
||||
$field: field.type,
|
||||
readOnly: !field.isFillable("update") ? true : undefined,
|
||||
writeOnly: !field.isFillable("create") ? true : undefined,
|
||||
...field.toJsonSchema()
|
||||
}))
|
||||
transformObject(_fields, (field) => {
|
||||
//const hidden = field.isHidden(options?.context);
|
||||
const fillable = field.isFillable(options?.context);
|
||||
return {
|
||||
title: field.config.label,
|
||||
$comment: field.config.description,
|
||||
$field: field.type,
|
||||
readOnly: !fillable ? true : undefined,
|
||||
...field.toJsonSchema()
|
||||
};
|
||||
}),
|
||||
{ additionalProperties: false }
|
||||
);
|
||||
|
||||
return clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
return options?.clean ? JSON.parse(JSON.stringify(schema)) : schema;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DB as DefaultDB } from "core";
|
||||
import { EventManager } from "core/events";
|
||||
import { sql } from "kysely";
|
||||
import { Connection } from "../connection/Connection";
|
||||
@@ -14,7 +15,18 @@ import { SchemaManager } from "../schema/SchemaManager";
|
||||
import { Entity } from "./Entity";
|
||||
import { type EntityData, Mutator, Repository } from "./index";
|
||||
|
||||
export class EntityManager<DB> {
|
||||
type EntitySchema<
|
||||
TBD extends object = DefaultDB,
|
||||
E extends Entity | keyof TBD | string = string
|
||||
> = E extends Entity<infer Name>
|
||||
? Name extends keyof TBD
|
||||
? Name
|
||||
: never
|
||||
: E extends keyof TBD
|
||||
? E
|
||||
: never;
|
||||
|
||||
export class EntityManager<TBD extends object = DefaultDB> {
|
||||
connection: Connection;
|
||||
|
||||
private _entities: Entity[] = [];
|
||||
@@ -50,7 +62,7 @@ export class EntityManager<DB> {
|
||||
* Forks the EntityManager without the EventManager.
|
||||
* This is useful when used inside an event handler.
|
||||
*/
|
||||
fork(): EntityManager<DB> {
|
||||
fork(): EntityManager {
|
||||
return new EntityManager(this._entities, this.connection, this._relations, this._indices);
|
||||
}
|
||||
|
||||
@@ -87,10 +99,27 @@ export class EntityManager<DB> {
|
||||
this.entities.push(entity);
|
||||
}
|
||||
|
||||
entity(name: string): Entity {
|
||||
const entity = this.entities.find((e) => e.name === name);
|
||||
__replaceEntity(entity: Entity, name: string | undefined = entity.name) {
|
||||
const entityIndex = this._entities.findIndex((e) => e.name === name);
|
||||
|
||||
if (entityIndex === -1) {
|
||||
throw new Error(`Entity "${name}" not found and cannot be replaced`);
|
||||
}
|
||||
|
||||
this._entities[entityIndex] = entity;
|
||||
|
||||
// caused issues because this.entity() was using a reference (for when initial config was given)
|
||||
}
|
||||
|
||||
entity(e: Entity | keyof TBD | string): Entity {
|
||||
// make sure to always retrieve by name
|
||||
const entity = this.entities.find((entity) =>
|
||||
e instanceof Entity ? entity.name === e.name : entity.name === e
|
||||
);
|
||||
|
||||
if (!entity) {
|
||||
throw new EntityNotDefinedException(name);
|
||||
// @ts-ignore
|
||||
throw new EntityNotDefinedException(e instanceof Entity ? e.name : e);
|
||||
}
|
||||
|
||||
return entity;
|
||||
@@ -162,28 +191,18 @@ export class EntityManager<DB> {
|
||||
return this.relations.relationReferencesOf(this.entity(entity_name));
|
||||
}
|
||||
|
||||
repository(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
repository<E extends Entity | keyof TBD | string>(
|
||||
entity: E
|
||||
): Repository<TBD, EntitySchema<TBD, E>> {
|
||||
return this.repo(entity);
|
||||
}
|
||||
|
||||
repo<E extends Entity>(
|
||||
_entity: E
|
||||
): Repository<
|
||||
DB,
|
||||
E extends Entity<infer Name> ? (Name extends keyof DB ? Name : never) : never
|
||||
> {
|
||||
return new Repository(this, _entity, this.emgr);
|
||||
repo<E extends Entity | keyof TBD | string>(entity: E): Repository<TBD, EntitySchema<TBD, E>> {
|
||||
return new Repository(this, this.entity(entity), this.emgr);
|
||||
}
|
||||
|
||||
_repo<TB extends keyof DB>(_entity: TB): Repository<DB, TB> {
|
||||
const entity = this.entity(_entity as any);
|
||||
return new Repository(this, entity, this.emgr);
|
||||
}
|
||||
|
||||
mutator(_entity: Entity | string) {
|
||||
const entity = _entity instanceof Entity ? _entity : this.entity(_entity);
|
||||
return new Mutator(this, entity, this.emgr);
|
||||
mutator<E extends Entity | keyof TBD | string>(entity: E): Mutator<TBD, EntitySchema<TBD, E>> {
|
||||
return new Mutator(this, this.entity(entity), this.emgr);
|
||||
}
|
||||
|
||||
addIndex(index: EntityIndex, force = false) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import type { DeleteQueryBuilder, InsertQueryBuilder, UpdateQueryBuilder } from "kysely";
|
||||
import { type TActionContext, WhereBuilder } from "..";
|
||||
@@ -25,8 +25,14 @@ export type MutatorResponse<T = EntityData[]> = {
|
||||
data: T;
|
||||
};
|
||||
|
||||
export class Mutator<DB> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
export class Mutator<
|
||||
TBD extends object = DefaultDB,
|
||||
TB extends keyof TBD = any,
|
||||
Output = TBD[TB],
|
||||
Input = Omit<Output, "id">
|
||||
> implements EmitsEvents
|
||||
{
|
||||
em: EntityManager<TBD>;
|
||||
entity: Entity;
|
||||
static readonly Events = MutatorEvents;
|
||||
emgr: EventManager<typeof MutatorEvents>;
|
||||
@@ -37,7 +43,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
this.__unstable_disable_system_entity_creation = value;
|
||||
}
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
@@ -47,13 +53,13 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
return this.em.connection.kysely;
|
||||
}
|
||||
|
||||
async getValidatedData(data: EntityData, context: TActionContext): Promise<EntityData> {
|
||||
async getValidatedData<Given = any>(data: Given, context: TActionContext): Promise<Given> {
|
||||
const entity = this.entity;
|
||||
if (!context) {
|
||||
throw new Error("Context must be provided for validation");
|
||||
}
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const keys = Object.keys(data as any);
|
||||
const validatedData: EntityData = {};
|
||||
|
||||
// get relational references/keys
|
||||
@@ -95,7 +101,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
throw new Error(`No data left to update "${entity.name}"`);
|
||||
}
|
||||
|
||||
return validatedData;
|
||||
return validatedData as Given;
|
||||
}
|
||||
|
||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||
@@ -120,7 +126,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
return { ...response, data: data[0]! };
|
||||
}
|
||||
|
||||
async insertOne(data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
async insertOne(data: Input): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
|
||||
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
|
||||
@@ -154,10 +160,10 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
|
||||
await this.emgr.emit(new Mutator.Events.MutatorInsertAfter({ entity, data: res.data }));
|
||||
|
||||
return res;
|
||||
return res as any;
|
||||
}
|
||||
|
||||
async updateOne(id: PrimaryFieldType, data: EntityData): Promise<MutatorResponse<EntityData>> {
|
||||
async updateOne(id: PrimaryFieldType, data: Partial<Input>): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for update");
|
||||
@@ -166,12 +172,16 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
|
||||
new Mutator.Events.MutatorUpdateBefore({
|
||||
entity,
|
||||
entityId: id,
|
||||
data: validatedData as any
|
||||
})
|
||||
);
|
||||
|
||||
const query = this.conn
|
||||
.updateTable(entity.name)
|
||||
.set(validatedData)
|
||||
.set(validatedData as any)
|
||||
.where(entity.id().name, "=", id)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
@@ -181,10 +191,10 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
return res as any;
|
||||
}
|
||||
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<EntityData>> {
|
||||
async deleteOne(id: PrimaryFieldType): Promise<MutatorResponse<Output>> {
|
||||
const entity = this.entity;
|
||||
if (!Number.isInteger(id)) {
|
||||
throw new Error("ID must be provided for deletion");
|
||||
@@ -203,7 +213,7 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);
|
||||
|
||||
return res;
|
||||
return res as any;
|
||||
}
|
||||
|
||||
private getValidOptions(options?: Partial<RepoQuery>): Partial<RepoQuery> {
|
||||
@@ -250,47 +260,62 @@ export class Mutator<DB> implements EmitsEvents {
|
||||
}
|
||||
|
||||
// @todo: decide whether entries should be deleted all at once or one by one (for events)
|
||||
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<EntityData>> {
|
||||
async deleteWhere(where?: RepoQuery["where"]): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const qb = this.appendWhere(this.conn.deleteFrom(entity.name), where).returning(
|
||||
entity.getSelect()
|
||||
);
|
||||
|
||||
//await this.emgr.emit(new Mutator.Events.MutatorDeleteBefore({ entity, entityId: id }));
|
||||
|
||||
const res = await this.many(qb);
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorDeleteAfter({ entity, entityId: id, data: res.data })
|
||||
);*/
|
||||
|
||||
return res;
|
||||
return (await this.many(qb)) as any;
|
||||
}
|
||||
|
||||
async updateWhere(
|
||||
data: EntityData,
|
||||
data: Partial<Input>,
|
||||
where?: RepoQuery["where"]
|
||||
): Promise<MutatorResponse<EntityData>> {
|
||||
): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
|
||||
const validatedData = await this.getValidatedData(data, "update");
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateBefore({ entity, entityId: id, data: validatedData })
|
||||
);*/
|
||||
|
||||
const query = this.appendWhere(this.conn.updateTable(entity.name), where)
|
||||
.set(validatedData)
|
||||
//.where(entity.id().name, "=", id)
|
||||
.set(validatedData as any)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
const res = await this.many(query);
|
||||
return (await this.many(query)) as any;
|
||||
}
|
||||
|
||||
/*await this.emgr.emit(
|
||||
new Mutator.Events.MutatorUpdateAfter({ entity, entityId: id, data: res.data })
|
||||
);*/
|
||||
async insertMany(data: Input[]): Promise<MutatorResponse<Output[]>> {
|
||||
const entity = this.entity;
|
||||
if (entity.type === "system" && this.__unstable_disable_system_entity_creation) {
|
||||
throw new Error(`Creation of system entity "${entity.name}" is disabled`);
|
||||
}
|
||||
|
||||
return res;
|
||||
const validated: any[] = [];
|
||||
for (const row of data) {
|
||||
const validatedData = {
|
||||
...entity.getDefaultObject(),
|
||||
...(await this.getValidatedData(row, "create"))
|
||||
};
|
||||
|
||||
// check if required fields are present
|
||||
const required = entity.getRequiredFields();
|
||||
for (const field of required) {
|
||||
if (
|
||||
typeof validatedData[field.name] === "undefined" ||
|
||||
validatedData[field.name] === null
|
||||
) {
|
||||
throw new Error(`Field "${field.name}" is required`);
|
||||
}
|
||||
}
|
||||
|
||||
validated.push(validatedData);
|
||||
}
|
||||
|
||||
const query = this.conn
|
||||
.insertInto(entity.name)
|
||||
.values(validated)
|
||||
.returning(entity.getSelect());
|
||||
|
||||
return (await this.many(query)) as any;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
@@ -43,20 +43,22 @@ export type RepositoryExistsResponse = RepositoryRawResponse & {
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEvents {
|
||||
em: EntityManager<DB>;
|
||||
export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = any>
|
||||
implements EmitsEvents
|
||||
{
|
||||
em: EntityManager<TBD>;
|
||||
entity: Entity;
|
||||
static readonly Events = RepositoryEvents;
|
||||
emgr: EventManager<typeof Repository.Events>;
|
||||
|
||||
constructor(em: EntityManager<DB>, entity: Entity, emgr?: EventManager<any>) {
|
||||
constructor(em: EntityManager<TBD>, entity: Entity, emgr?: EventManager<any>) {
|
||||
this.em = em;
|
||||
this.entity = entity;
|
||||
this.emgr = emgr ?? new EventManager(MutatorEvents);
|
||||
}
|
||||
|
||||
private cloneFor(entity: Entity) {
|
||||
return new Repository(this.em, entity, this.emgr);
|
||||
return new Repository(this.em, this.em.entity(entity), this.emgr);
|
||||
}
|
||||
|
||||
private get conn() {
|
||||
@@ -92,7 +94,10 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
if (invalid.length > 0) {
|
||||
throw new InvalidSearchParamsException(
|
||||
`Invalid select field(s): ${invalid.join(", ")}`
|
||||
);
|
||||
).context({
|
||||
entity: entity.name,
|
||||
valid: validated.select
|
||||
});
|
||||
}
|
||||
|
||||
validated.select = options.select;
|
||||
@@ -201,7 +206,10 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("many error", e, compiled);
|
||||
if (e instanceof Error) {
|
||||
console.error("[ERROR] Repository.performQuery", e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -269,7 +277,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
async findId(
|
||||
id: PrimaryFieldType,
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB]>> {
|
||||
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery(
|
||||
{
|
||||
..._options,
|
||||
@@ -285,7 +293,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
async findOne(
|
||||
where: RepoQuery["where"],
|
||||
_options?: Partial<Omit<RepoQuery, "where" | "limit" | "offset">>
|
||||
): Promise<RepositoryResponse<DB[TB] | undefined>> {
|
||||
): Promise<RepositoryResponse<TBD[TB] | undefined>> {
|
||||
const { qb, options } = this.buildQuery({
|
||||
..._options,
|
||||
where,
|
||||
@@ -295,7 +303,7 @@ export class Repository<DB = any, TB extends keyof DB = any> implements EmitsEve
|
||||
return this.single(qb, options) as any;
|
||||
}
|
||||
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<DB[TB][]>> {
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
|
||||
const { qb, options } = this.buildQuery(_options);
|
||||
//console.log("findMany:options", options);
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ export class TextField<Required extends true | false = false> extends Field<
|
||||
);
|
||||
}
|
||||
|
||||
if (this.config.pattern && value && !new RegExp(this.config.pattern).test(value)) {
|
||||
throw new TransformPersistFailedException(
|
||||
`Field "${this.name}" must match the pattern ${this.config.pattern}`
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+26
-4
@@ -1,4 +1,4 @@
|
||||
import type { EntityData, Field } from "data";
|
||||
import type { EntityData, EntityManager, Field } from "data";
|
||||
import { transform } from "lodash-es";
|
||||
|
||||
export function getDefaultValues(fields: Field[], data: EntityData): EntityData {
|
||||
@@ -18,6 +18,7 @@ export function getChangeSet(
|
||||
data: EntityData,
|
||||
fields: Field[]
|
||||
): EntityData {
|
||||
//console.log("getChangeSet", formData, data);
|
||||
return transform(
|
||||
formData,
|
||||
(acc, _value, key) => {
|
||||
@@ -26,11 +27,12 @@ export function getChangeSet(
|
||||
if (!field || field.isVirtual()) return;
|
||||
const value = _value === "" ? null : _value;
|
||||
|
||||
const newValue = field.getValue(value, "submit");
|
||||
// normalize to null if undefined
|
||||
const newValue = field.getValue(value, "submit") || null;
|
||||
// @todo: add typing for "action"
|
||||
if (action === "create" || newValue !== data[key]) {
|
||||
acc[key] = newValue;
|
||||
console.log("changed", {
|
||||
/*console.log("changed", {
|
||||
key,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
@@ -38,7 +40,7 @@ export function getChangeSet(
|
||||
newValue,
|
||||
new: value,
|
||||
sent: acc[key]
|
||||
});
|
||||
});*/
|
||||
} else {
|
||||
//console.log("no change", key, value, data[key]);
|
||||
}
|
||||
@@ -46,3 +48,23 @@ export function getChangeSet(
|
||||
{} as typeof formData
|
||||
);
|
||||
}
|
||||
|
||||
export function readableEmJson(_em: EntityManager) {
|
||||
return {
|
||||
entities: _em.entities.map((e) => ({
|
||||
name: e.name,
|
||||
fields: e.fields.map((f) => f.name),
|
||||
type: e.type
|
||||
})),
|
||||
indices: _em.indices.map((i) => ({
|
||||
name: i.name,
|
||||
entity: i.entity.name,
|
||||
fields: i.fields.map((f) => f.name),
|
||||
unique: i.unique
|
||||
})),
|
||||
relations: _em.relations.all.map((r) => ({
|
||||
name: r.getName(),
|
||||
...r.toJSON()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlCon
|
||||
export { SqliteConnection } from "./connection/SqliteConnection";
|
||||
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
|
||||
|
||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||
|
||||
export const DatabaseEvents = {
|
||||
...MutatorEvents,
|
||||
...RepositoryEvents
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { DummyConnection } from "data/connection/DummyConnection";
|
||||
import { EntityManager } from "data/entities/EntityManager";
|
||||
import type { Generated } from "kysely";
|
||||
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
import {
|
||||
BooleanField,
|
||||
type BooleanFieldConfig,
|
||||
@@ -5,6 +10,8 @@ import {
|
||||
type DateFieldConfig,
|
||||
Entity,
|
||||
type EntityConfig,
|
||||
EntityIndex,
|
||||
type EntityRelation,
|
||||
EnumField,
|
||||
type EnumFieldConfig,
|
||||
type Field,
|
||||
@@ -25,15 +32,14 @@ import {
|
||||
type TEntityType,
|
||||
TextField,
|
||||
type TextFieldConfig
|
||||
} from "data";
|
||||
import type { Generated } from "kysely";
|
||||
import { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
} from "../index";
|
||||
|
||||
type Options<Config = any> = {
|
||||
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
||||
field_name: string;
|
||||
config: Config;
|
||||
is_required: boolean;
|
||||
another?: string;
|
||||
};
|
||||
|
||||
const FieldMap = {
|
||||
@@ -239,7 +245,93 @@ export function relation<Local extends Entity>(local: Local) {
|
||||
};
|
||||
}
|
||||
|
||||
type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
|
||||
export function index<E extends Entity>(entity: E) {
|
||||
return {
|
||||
on: (fields: (keyof InsertSchema<E>)[], unique?: boolean) => {
|
||||
const _fields = fields.map((f) => {
|
||||
const field = entity.field(f as any);
|
||||
if (!field) {
|
||||
throw new Error(`Field "${String(f)}" not found on entity "${entity.name}"`);
|
||||
}
|
||||
return field;
|
||||
});
|
||||
return new EntityIndex(entity, _fields, unique);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EntityManagerPrototype<Entities extends Record<string, Entity>> extends EntityManager<
|
||||
Schema<Entities>
|
||||
> {
|
||||
constructor(
|
||||
public __entities: Entities,
|
||||
relations: EntityRelation[] = [],
|
||||
indices: EntityIndex[] = []
|
||||
) {
|
||||
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
||||
}
|
||||
}
|
||||
|
||||
type Chained<R extends Record<string, (...args: any[]) => any>> = {
|
||||
[K in keyof R]: R[K] extends (...args: any[]) => any
|
||||
? (...args: Parameters<R[K]>) => Chained<R>
|
||||
: never;
|
||||
};
|
||||
type ChainedFn<
|
||||
Fn extends (...args: any[]) => Record<string, (...args: any[]) => any>,
|
||||
Return extends ReturnType<Fn> = ReturnType<Fn>
|
||||
> = (e: Entity) => {
|
||||
[K in keyof Return]: (...args: Parameters<Return[K]>) => Chained<Return>;
|
||||
};
|
||||
|
||||
export function em<Entities extends Record<string, Entity>>(
|
||||
entities: Entities,
|
||||
schema?: (
|
||||
fns: { relation: ChainedFn<typeof relation>; index: ChainedFn<typeof index> },
|
||||
entities: Entities
|
||||
) => void
|
||||
) {
|
||||
const relations: EntityRelation[] = [];
|
||||
const indices: EntityIndex[] = [];
|
||||
|
||||
const relationProxy = (e: Entity) => {
|
||||
return new Proxy(relation(e), {
|
||||
get(target, prop) {
|
||||
return (...args: any[]) => {
|
||||
relations.push(target[prop](...args));
|
||||
return relationProxy(e);
|
||||
};
|
||||
}
|
||||
}) as any;
|
||||
};
|
||||
|
||||
const indexProxy = (e: Entity) => {
|
||||
return new Proxy(index(e), {
|
||||
get(target, prop) {
|
||||
return (...args: any[]) => {
|
||||
indices.push(target[prop](...args));
|
||||
return indexProxy(e);
|
||||
};
|
||||
}
|
||||
}) as any;
|
||||
};
|
||||
|
||||
if (schema) {
|
||||
schema({ relation: relationProxy, index: indexProxy }, entities);
|
||||
}
|
||||
|
||||
const e = new EntityManagerPrototype(entities, relations, indices);
|
||||
return {
|
||||
DB: e.__entities as unknown as Schemas<Entities>,
|
||||
entities: e.__entities,
|
||||
relations,
|
||||
indices,
|
||||
toJSON: () =>
|
||||
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">
|
||||
};
|
||||
}
|
||||
|
||||
export type InferEntityFields<T> = T extends Entity<infer _N, infer Fields>
|
||||
? {
|
||||
[K in keyof Fields]: Fields[K] extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
@@ -284,12 +376,16 @@ type OptionalUndefined<
|
||||
}
|
||||
>;
|
||||
|
||||
type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
|
||||
export type InferField<Field> = Field extends { _type: infer Type; _required: infer Required }
|
||||
? Required extends true
|
||||
? Type
|
||||
: Type | undefined
|
||||
: never;
|
||||
|
||||
export type Schemas<T extends Record<string, Entity>> = {
|
||||
[K in keyof T]: Schema<T[K]>;
|
||||
};
|
||||
|
||||
export type InsertSchema<T> = Simplify<OptionalUndefined<InferEntityFields<T>>>;
|
||||
export type Schema<T> = { id: Generated<number> } & InsertSchema<T>;
|
||||
export type Schema<T> = Simplify<{ id: Generated<number> } & InsertSchema<T>>;
|
||||
export type FieldSchema<T> = Simplify<OptionalUndefined<InferFields<T>>>;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { transformObject } from "core/utils";
|
||||
import { Entity, type Field } from "data";
|
||||
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
|
||||
|
||||
export function constructEntity(name: string, entityConfig: TAppDataEntity) {
|
||||
const fields = transformObject(entityConfig.fields ?? {}, (fieldConfig, name) => {
|
||||
const { type } = fieldConfig;
|
||||
if (!(type in FIELDS)) {
|
||||
throw new Error(`Field type "${type}" not found`);
|
||||
}
|
||||
|
||||
const { field } = FIELDS[type as any];
|
||||
const returnal = new field(name, fieldConfig.config) as Field;
|
||||
return returnal;
|
||||
});
|
||||
|
||||
return new Entity(
|
||||
name,
|
||||
Object.values(fields),
|
||||
entityConfig.config as any,
|
||||
entityConfig.type as any
|
||||
);
|
||||
}
|
||||
|
||||
export function constructRelation(
|
||||
relationConfig: TAppDataRelation,
|
||||
resolver: (name: Entity | string) => Entity
|
||||
) {
|
||||
return new RELATIONS[relationConfig.type].cls(
|
||||
resolver(relationConfig.source),
|
||||
resolver(relationConfig.target),
|
||||
relationConfig.config
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Type,
|
||||
Value
|
||||
} from "core/utils";
|
||||
import type { Simplify } from "type-fest";
|
||||
import { WhereBuilder } from "../entities";
|
||||
|
||||
const NumberOrString = (options: SchemaOptions = {}) =>
|
||||
@@ -19,17 +18,25 @@ const limit = NumberOrString({ default: 10 });
|
||||
const offset = NumberOrString({ default: 0 });
|
||||
|
||||
// @todo: allow "id" and "-id"
|
||||
const sort_default = { by: "id", dir: "asc" };
|
||||
const sort = Type.Transform(
|
||||
Type.Union(
|
||||
[Type.String(), Type.Object({ by: Type.String(), dir: StringEnum(["asc", "desc"]) })],
|
||||
{
|
||||
default: { by: "id", dir: "asc" }
|
||||
default: sort_default
|
||||
}
|
||||
)
|
||||
)
|
||||
.Decode((value) => {
|
||||
if (typeof value === "string") {
|
||||
return JSON.parse(value);
|
||||
if (/^-?[a-zA-Z_][a-zA-Z0-9_.]*$/.test(value)) {
|
||||
const dir = value[0] === "-" ? "desc" : "asc";
|
||||
return { by: dir === "desc" ? value.slice(1) : value, dir };
|
||||
} else if (/^{.*}$/.test(value)) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
return sort_default;
|
||||
}
|
||||
return value;
|
||||
})
|
||||
@@ -72,6 +79,6 @@ export const querySchema = Type.Object(
|
||||
}
|
||||
);
|
||||
|
||||
export type RepoQueryIn = Simplify<Static<typeof querySchema>>;
|
||||
export type RepoQueryIn = Static<typeof querySchema>;
|
||||
export type RepoQuery = Required<StaticDecode<typeof querySchema>>;
|
||||
export const defaultQuerySchema = Value.Default(querySchema, {}) as RepoQuery;
|
||||
|
||||
+7
-2
@@ -4,8 +4,13 @@ export {
|
||||
getDefaultConfig,
|
||||
getDefaultSchema,
|
||||
type ModuleConfigs,
|
||||
type ModuleSchemas
|
||||
} from "modules/ModuleManager";
|
||||
type ModuleSchemas,
|
||||
type ModuleManagerOptions,
|
||||
type ModuleBuildContext
|
||||
} from "./modules/ModuleManager";
|
||||
|
||||
export * as middlewares from "modules/middlewares";
|
||||
export { registries } from "modules/registries";
|
||||
|
||||
export type * from "./adapter";
|
||||
export { Api, type ApiOptions } from "./Api";
|
||||
|
||||
+21
-27
@@ -1,12 +1,12 @@
|
||||
import { EntityIndex, type EntityManager } from "data";
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { type Entity, EntityIndex, type EntityManager } from "data";
|
||||
import { type FileUploadedEventData, Storage, type StorageAdapter } from "media";
|
||||
import { Module } from "modules/Module";
|
||||
import {
|
||||
type FieldSchema,
|
||||
type InferFields,
|
||||
type Schema,
|
||||
boolean,
|
||||
datetime,
|
||||
em,
|
||||
entity,
|
||||
json,
|
||||
number,
|
||||
@@ -16,9 +16,9 @@ import { MediaController } from "./api/MediaController";
|
||||
import { ADAPTERS, buildMediaSchema, type mediaConfigSchema, registry } from "./media-schema";
|
||||
|
||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||
declare global {
|
||||
declare module "core" {
|
||||
interface DB {
|
||||
media: MediaFieldSchema;
|
||||
media: { id: PrimaryFieldType } & MediaFieldSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,18 +47,12 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
this.setupListeners();
|
||||
this.ctx.server.route(this.basepath, new MediaController(this).getController());
|
||||
|
||||
// @todo: add check for media entity
|
||||
const mediaEntity = this.getMediaEntity();
|
||||
if (!this.ctx.em.hasEntity(mediaEntity)) {
|
||||
this.ctx.em.addEntity(mediaEntity);
|
||||
}
|
||||
|
||||
const pathIndex = new EntityIndex(mediaEntity, [mediaEntity.field("path")!], true);
|
||||
if (!this.ctx.em.hasIndex(pathIndex)) {
|
||||
this.ctx.em.addIndex(pathIndex);
|
||||
}
|
||||
|
||||
// @todo: check indices
|
||||
const media = this.getMediaEntity(true);
|
||||
this.ensureSchema(
|
||||
em({ [media.name as "media"]: media }, ({ index }, { media }) => {
|
||||
index(media).on(["path"], true).on(["reference"]);
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
@@ -103,23 +97,23 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
metadata: json()
|
||||
};
|
||||
|
||||
getMediaEntity() {
|
||||
getMediaEntity(forceCreate?: boolean): Entity<"media", typeof AppMedia.mediaFields> {
|
||||
const entity_name = this.config.entity_name;
|
||||
if (!this.em.hasEntity(entity_name)) {
|
||||
return entity(entity_name, AppMedia.mediaFields, undefined, "system");
|
||||
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
||||
return entity(entity_name as "media", AppMedia.mediaFields, undefined, "system");
|
||||
}
|
||||
|
||||
return this.em.entity(entity_name);
|
||||
return this.em.entity(entity_name) as any;
|
||||
}
|
||||
|
||||
get em(): EntityManager<DB> {
|
||||
get em(): EntityManager {
|
||||
return this.ctx.em;
|
||||
}
|
||||
|
||||
private setupListeners() {
|
||||
//const media = this._entity;
|
||||
const { emgr, em } = this.ctx;
|
||||
const media = this.getMediaEntity();
|
||||
const media = this.getMediaEntity().name as "media";
|
||||
|
||||
// when file is uploaded, sync with media entity
|
||||
// @todo: need a way for singleton events!
|
||||
@@ -140,10 +134,10 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
Storage.Events.FileDeletedEvent,
|
||||
async (e) => {
|
||||
// simple file deletion sync
|
||||
const item = await em.repo(media).findOne({ path: e.params.name });
|
||||
if (item.data) {
|
||||
console.log("item.data", item.data);
|
||||
await em.mutator(media).deleteOne(item.data.id);
|
||||
const { data } = await em.repo(media).findOne({ path: e.params.name });
|
||||
if (data) {
|
||||
console.log("item.data", data);
|
||||
await em.mutator(media).deleteOne(data.id);
|
||||
}
|
||||
|
||||
console.log("App:storage:file deleted", e);
|
||||
|
||||
@@ -10,11 +10,11 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
};
|
||||
}
|
||||
|
||||
async getFiles() {
|
||||
getFiles() {
|
||||
return this.get(["files"]);
|
||||
}
|
||||
|
||||
async getFile(filename: string) {
|
||||
getFile(filename: string) {
|
||||
return this.get(["file", filename]);
|
||||
}
|
||||
|
||||
@@ -32,13 +32,13 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(file: File) {
|
||||
uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return this.post(["upload"], formData);
|
||||
}
|
||||
|
||||
async deleteFile(filename: string) {
|
||||
deleteFile(filename: string) {
|
||||
return this.delete(["file", filename]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { type ClassController, tbValidator as tb } from "core";
|
||||
import { tbValidator as tb } from "core";
|
||||
import { Type } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import type { StorageAdapter } from "media";
|
||||
import { StorageEvents } from "media";
|
||||
import { getRandomizedFilename } from "media";
|
||||
import { StorageEvents, getRandomizedFilename } from "media";
|
||||
import { Controller } from "modules/Controller";
|
||||
import type { AppMedia } from "../AppMedia";
|
||||
import { MediaField } from "../MediaField";
|
||||
|
||||
@@ -12,8 +11,10 @@ const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
|
||||
export class MediaController implements ClassController {
|
||||
constructor(private readonly media: AppMedia) {}
|
||||
export class MediaController extends Controller {
|
||||
constructor(private readonly media: AppMedia) {
|
||||
super();
|
||||
}
|
||||
|
||||
private getStorageAdapter(): StorageAdapter {
|
||||
return this.getStorage().getAdapter();
|
||||
@@ -23,11 +24,11 @@ export class MediaController implements ClassController {
|
||||
return this.media.storage;
|
||||
}
|
||||
|
||||
getController(): Hono<any> {
|
||||
override getController() {
|
||||
// @todo: multiple providers?
|
||||
// @todo: implement range requests
|
||||
|
||||
const hono = new Hono();
|
||||
const { auth } = this.middlewares;
|
||||
const hono = this.create().use(auth());
|
||||
|
||||
// get files list (temporary)
|
||||
hono.get("/files", async (c) => {
|
||||
@@ -107,7 +108,7 @@ export class MediaController implements ClassController {
|
||||
return c.json({ error: `Invalid field "${field_name}"` }, 400);
|
||||
}
|
||||
|
||||
const mediaEntity = this.media.getMediaEntity();
|
||||
const media_entity = this.media.getMediaEntity().name as "media";
|
||||
const reference = `${entity_name}.${field_name}`;
|
||||
const mediaRef = {
|
||||
scope: field_name,
|
||||
@@ -117,11 +118,10 @@ export class MediaController implements ClassController {
|
||||
|
||||
// check max items
|
||||
const max_items = field.getMaxItems();
|
||||
const ids_to_delete: number[] = [];
|
||||
const id_field = mediaEntity.getPrimaryField().name;
|
||||
const paths_to_delete: string[] = [];
|
||||
if (max_items) {
|
||||
const { overwrite } = c.req.valid("query");
|
||||
const { count } = await this.media.em.repository(mediaEntity).count(mediaRef);
|
||||
const { count } = await this.media.em.repository(media_entity).count(mediaRef);
|
||||
|
||||
// if there are more than or equal to max items
|
||||
if (count >= max_items) {
|
||||
@@ -140,18 +140,18 @@ export class MediaController implements ClassController {
|
||||
}
|
||||
|
||||
// collect items to delete
|
||||
const deleteRes = await this.media.em.repo(mediaEntity).findMany({
|
||||
select: [id_field],
|
||||
const deleteRes = await this.media.em.repo(media_entity).findMany({
|
||||
select: ["path"],
|
||||
where: mediaRef,
|
||||
sort: {
|
||||
by: id_field,
|
||||
by: "id",
|
||||
dir: "asc"
|
||||
},
|
||||
limit: count - max_items + 1
|
||||
});
|
||||
|
||||
if (deleteRes.data && deleteRes.data.length > 0) {
|
||||
deleteRes.data.map((item) => ids_to_delete.push(item[id_field]));
|
||||
deleteRes.data.map((item) => paths_to_delete.push(item.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,19 +169,20 @@ export class MediaController implements ClassController {
|
||||
const file_name = getRandomizedFilename(file as File);
|
||||
const info = await this.getStorage().uploadFile(file, file_name, true);
|
||||
|
||||
const mutator = this.media.em.mutator(mediaEntity);
|
||||
const mutator = this.media.em.mutator(media_entity);
|
||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||
const result = await mutator.insertOne({
|
||||
...this.media.uploadedEventDataToMediaPayload(info),
|
||||
...mediaRef
|
||||
});
|
||||
} as any);
|
||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||
|
||||
// delete items if needed
|
||||
if (ids_to_delete.length > 0) {
|
||||
await this.media.em
|
||||
.mutator(mediaEntity)
|
||||
.deleteWhere({ [id_field]: { $in: ids_to_delete } });
|
||||
if (paths_to_delete.length > 0) {
|
||||
// delete files from db & adapter
|
||||
for (const path of paths_to_delete) {
|
||||
await this.getStorage().deleteFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ok: true, result: result.data, ...info });
|
||||
|
||||
+6
-14
@@ -17,10 +17,6 @@ import {
|
||||
import { type S3AdapterConfig, StorageS3Adapter } from "./storage/adapters/StorageS3Adapter";
|
||||
|
||||
export { StorageS3Adapter, type S3AdapterConfig, StorageCloudinaryAdapter, type CloudinaryConfig };
|
||||
/*export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig
|
||||
} from "./storage/adapters/StorageLocalAdapter";*/
|
||||
|
||||
export * as StorageEvents from "./storage/events";
|
||||
export { type FileUploadedEventData } from "./storage/events";
|
||||
@@ -31,16 +27,12 @@ type ClassThatImplements<T> = Constructor<T> & { prototype: T };
|
||||
export const MediaAdapterRegistry = new Registry<{
|
||||
cls: ClassThatImplements<StorageAdapter>;
|
||||
schema: TObject;
|
||||
}>().set({
|
||||
s3: {
|
||||
cls: StorageS3Adapter,
|
||||
schema: StorageS3Adapter.prototype.getSchema()
|
||||
},
|
||||
cloudinary: {
|
||||
cls: StorageCloudinaryAdapter,
|
||||
schema: StorageCloudinaryAdapter.prototype.getSchema()
|
||||
}
|
||||
});
|
||||
}>((cls: ClassThatImplements<StorageAdapter>) => ({
|
||||
cls,
|
||||
schema: cls.prototype.getSchema() as TObject
|
||||
}))
|
||||
.register("s3", StorageS3Adapter)
|
||||
.register("cloudinary", StorageCloudinaryAdapter);
|
||||
|
||||
export const Adapters = {
|
||||
s3: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Const, Type, objectTransform } from "core/utils";
|
||||
import { Const, type Static, Type, objectTransform } from "core/utils";
|
||||
import { Adapters } from "media";
|
||||
import { registries } from "modules/registries";
|
||||
|
||||
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
|
||||
}
|
||||
|
||||
export const mediaConfigSchema = buildMediaSchema();
|
||||
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { type Static, Type, parse } from "core/utils";
|
||||
import type {
|
||||
FileBody,
|
||||
FileListObject,
|
||||
FileMeta,
|
||||
FileUploadPayload,
|
||||
StorageAdapter
|
||||
} from "../../Storage";
|
||||
import { guessMimeType } from "../../mime-types";
|
||||
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
|
||||
import { guess } from "../../mime-types-tiny";
|
||||
|
||||
export const localAdapterConfig = Type.Object(
|
||||
{
|
||||
path: Type.String()
|
||||
path: Type.String({ default: "./" })
|
||||
},
|
||||
{ title: "Local" }
|
||||
);
|
||||
@@ -89,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter {
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const content = await readFile(`${this.config.path}/${key}`);
|
||||
const mimeType = guessMimeType(key);
|
||||
const mimeType = guess(key);
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
@@ -111,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter {
|
||||
async getObjectMeta(key: string): Promise<FileMeta> {
|
||||
const stats = await stat(`${this.config.path}/${key}`);
|
||||
return {
|
||||
type: guessMimeType(key) || "application/octet-stream",
|
||||
type: guess(key) || "application/octet-stream",
|
||||
size: stats.size
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export const Q = {
|
||||
video: ["mp4", "webm"],
|
||||
audio: ["ogg"],
|
||||
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"],
|
||||
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
|
||||
application: ["zip", "xml", "toml", "json", "json5"],
|
||||
font: ["woff", "woff2", "ttf", "otf"]
|
||||
} as const;
|
||||
|
||||
// reduced
|
||||
const c = {
|
||||
vnd: "vnd.openxmlformats-officedocument",
|
||||
z: "application/x-7z-compressed",
|
||||
t: (w = "plain") => `text/${w}`,
|
||||
a: (w = "octet-stream") => `application/${w}`,
|
||||
i: (w) => `image/${w}`,
|
||||
v: (w) => `video/${w}`
|
||||
} as const;
|
||||
export const M = new Map<string, string>([
|
||||
["7z", c.z],
|
||||
["7zip", c.z],
|
||||
["ai", c.a("pdf")],
|
||||
["apk", c.a("vnd.android.package-archive")],
|
||||
["doc", c.a("msword")],
|
||||
["docx", `${c.vnd}.wordprocessingml.document`],
|
||||
["eps", c.a("postscript")],
|
||||
["epub", c.a("epub+zip")],
|
||||
["ini", c.t()],
|
||||
["jar", c.a("java-archive")],
|
||||
["jsonld", c.a("ld+json")],
|
||||
["jpg", c.i("jpeg")],
|
||||
["log", c.t()],
|
||||
["m3u", c.t()],
|
||||
["m3u8", c.a("vnd.apple.mpegurl")],
|
||||
["manifest", c.t("cache-manifest")],
|
||||
["md", c.t("markdown")],
|
||||
["mkv", c.v("x-matroska")],
|
||||
["mp3", c.a("mpeg")],
|
||||
["mobi", c.a("x-mobipocket-ebook")],
|
||||
["ppt", c.a("powerpoint")],
|
||||
["pptx", `${c.vnd}.presentationml.presentation`],
|
||||
["qt", c.v("quicktime")],
|
||||
["svg", c.i("svg+xml")],
|
||||
["tif", c.i("tiff")],
|
||||
["tsv", c.t("tab-separated-values")],
|
||||
["tgz", c.a("x-tar")],
|
||||
["txt", c.t()],
|
||||
["text", c.t()],
|
||||
["vcd", c.a("x-cdlink")],
|
||||
["vcs", c.t("x-vcalendar")],
|
||||
["wav", c.a("x-wav")],
|
||||
["webmanifest", c.a("manifest+json")],
|
||||
["xls", c.a("vnd.ms-excel")],
|
||||
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
|
||||
["yml", c.t("yaml")]
|
||||
]);
|
||||
|
||||
export function guess(f: string): string {
|
||||
try {
|
||||
const e = f.split(".").pop() as string;
|
||||
if (!e) {
|
||||
return c.a();
|
||||
}
|
||||
|
||||
// try quick first
|
||||
for (const [t, _e] of Object.entries(Q)) {
|
||||
// @ts-ignore
|
||||
if (_e.includes(e)) {
|
||||
return `${t}/${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
return M.get(e!) as string;
|
||||
} catch (e) {
|
||||
return c.a();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ServerEnv } from "modules/Module";
|
||||
import * as middlewares from "modules/middlewares";
|
||||
|
||||
export class Controller {
|
||||
protected middlewares = middlewares;
|
||||
|
||||
protected create(): Hono<ServerEnv> {
|
||||
return Controller.createServer();
|
||||
}
|
||||
|
||||
static createServer(): Hono<ServerEnv> {
|
||||
return new Hono<ServerEnv>();
|
||||
}
|
||||
|
||||
getController(): Hono<ServerEnv> {
|
||||
return this.create();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
import type { App } from "App";
|
||||
import type { Guard } from "auth";
|
||||
import { SchemaObject } from "core";
|
||||
import type { EventManager } from "core/events";
|
||||
import type { Static, TSchema } from "core/utils";
|
||||
import type { Connection, EntityManager } from "data";
|
||||
import type { Connection, EntityIndex, EntityManager, em as prototypeEm } from "data";
|
||||
import { Entity } from "data";
|
||||
import type { Hono } from "hono";
|
||||
|
||||
export type ServerEnv = {
|
||||
Variables: {
|
||||
app?: App;
|
||||
// to prevent resolving auth multiple times
|
||||
auth_resolved?: boolean;
|
||||
// to only register once
|
||||
auth_registered?: boolean;
|
||||
// whether or not to bypass auth
|
||||
auth_skip?: boolean;
|
||||
html?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ModuleBuildContext = {
|
||||
connection: Connection;
|
||||
server: Hono<any>;
|
||||
em: EntityManager<any>;
|
||||
server: Hono<ServerEnv>;
|
||||
em: EntityManager;
|
||||
emgr: EventManager<any>;
|
||||
guard: Guard;
|
||||
flags: (typeof Module)["ctx_flags"];
|
||||
};
|
||||
|
||||
export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = Static<Schema>> {
|
||||
@@ -33,6 +49,15 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
||||
});
|
||||
}
|
||||
|
||||
static ctx_flags = {
|
||||
sync_required: false,
|
||||
ctx_reload_required: false
|
||||
} as {
|
||||
// signal that a sync is required at the end of build
|
||||
sync_required: boolean;
|
||||
ctx_reload_required: boolean;
|
||||
};
|
||||
|
||||
onBeforeUpdate(from: ConfigSchema, to: ConfigSchema): ConfigSchema | Promise<ConfigSchema> {
|
||||
return to;
|
||||
}
|
||||
@@ -78,6 +103,10 @@ export abstract class Module<Schema extends TSchema = TSchema, ConfigSchema = St
|
||||
return this._schema;
|
||||
}
|
||||
|
||||
// action performed when server has been initialized
|
||||
// can be used to assign global middlewares
|
||||
onServerInit(hono: Hono<ServerEnv>) {}
|
||||
|
||||
get ctx() {
|
||||
if (!this._ctx) {
|
||||
throw new Error("Context not set");
|
||||
@@ -115,4 +144,44 @@ 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) {
|
||||
// check fields
|
||||
if (!this.ctx.em.hasEntity(entity.name)) {
|
||||
this.ctx.em.addEntity(entity);
|
||||
this.ctx.flags.sync_required = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const instance = this.ctx.em.entity(entity.name);
|
||||
|
||||
// if exists, check all fields required are there
|
||||
// @todo: check if the field also equal
|
||||
for (const field of instance.fields) {
|
||||
const _field = entity.field(field.name);
|
||||
if (!_field) {
|
||||
entity.addField(field);
|
||||
this.ctx.flags.sync_required = true;
|
||||
}
|
||||
}
|
||||
|
||||
// replace entity (mainly to keep the ensured type)
|
||||
this.ctx.em.__replaceEntity(
|
||||
new Entity(entity.name, entity.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;
|
||||
}
|
||||
}
|
||||
|
||||
+166
-62
@@ -1,4 +1,4 @@
|
||||
import type { PrimaryFieldType } from "core";
|
||||
import { type PrimaryFieldType, isDebug } from "core";
|
||||
import { encodeSearch } from "core/utils";
|
||||
|
||||
export type { PrimaryFieldType };
|
||||
@@ -10,6 +10,7 @@ export type BaseModuleApiOptions = {
|
||||
token_transport?: "header" | "cookie" | "none";
|
||||
};
|
||||
|
||||
/** @deprecated */
|
||||
export type ApiResponse<Data = any> = {
|
||||
success: boolean;
|
||||
status: number;
|
||||
@@ -18,7 +19,11 @@ export type ApiResponse<Data = any> = {
|
||||
res: Response;
|
||||
};
|
||||
|
||||
export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
||||
export type TInput = string | (string | number | PrimaryFieldType)[];
|
||||
|
||||
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
|
||||
protected fetcher?: typeof fetch;
|
||||
|
||||
constructor(protected readonly _options: Partial<Options> = {}) {}
|
||||
|
||||
protected getDefaultOptions(): Partial<Options> {
|
||||
@@ -35,14 +40,15 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
||||
}
|
||||
|
||||
protected getUrl(path: string) {
|
||||
return this.options.host + (this.options.basepath + "/" + path).replace(/\/\//g, "/");
|
||||
const basepath = this.options.basepath ?? "";
|
||||
return this.options.host + (basepath + "/" + path).replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
protected async request<Data = any>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
protected request<Data = any>(
|
||||
_input: TInput,
|
||||
_query?: Record<string, any> | URLSearchParams,
|
||||
_init?: RequestInit
|
||||
): Promise<ApiResponse<Data>> {
|
||||
): FetchPromise<ResponseObject<Data>> {
|
||||
const method = _init?.method ?? "GET";
|
||||
const input = Array.isArray(_input) ? _input.join("/") : _input;
|
||||
let url = this.getUrl(input);
|
||||
@@ -78,14 +84,130 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("url", url);
|
||||
const res = await fetch(url, {
|
||||
const request = new Request(url, {
|
||||
..._init,
|
||||
method,
|
||||
body,
|
||||
headers
|
||||
});
|
||||
|
||||
return new FetchPromise(request, {
|
||||
fetcher: this.fetcher
|
||||
});
|
||||
}
|
||||
|
||||
get<Data = any>(
|
||||
_input: TInput,
|
||||
_query?: Record<string, any> | URLSearchParams,
|
||||
_init?: RequestInit
|
||||
) {
|
||||
return this.request<Data>(_input, _query, {
|
||||
..._init,
|
||||
method: "GET"
|
||||
});
|
||||
}
|
||||
|
||||
post<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
body,
|
||||
method: "POST"
|
||||
});
|
||||
}
|
||||
|
||||
patch<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
body,
|
||||
method: "PATCH"
|
||||
});
|
||||
}
|
||||
|
||||
put<Data = any>(_input: TInput, body?: any, _init?: RequestInit) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
body,
|
||||
method: "PUT"
|
||||
});
|
||||
}
|
||||
|
||||
delete<Data = any>(_input: TInput, _init?: RequestInit) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
method: "DELETE"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ResponseObject<Body = any, Data = Body extends { data: infer R } ? R : Body> = Data & {
|
||||
raw: Response;
|
||||
res: Response;
|
||||
data: Data;
|
||||
body: Body;
|
||||
ok: boolean;
|
||||
status: number;
|
||||
toJSON(): Data;
|
||||
};
|
||||
|
||||
export function createResponseProxy<Body = any, Data = any>(
|
||||
raw: Response,
|
||||
body: Body,
|
||||
data?: Data
|
||||
): ResponseObject<Body, Data> {
|
||||
const actualData = data ?? (body as unknown as Data);
|
||||
const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"];
|
||||
|
||||
return new Proxy(actualData as any, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "raw" || prop === "res") return raw;
|
||||
if (prop === "body") return body;
|
||||
if (prop === "data") return data;
|
||||
if (prop === "ok") return raw.ok;
|
||||
if (prop === "status") return raw.status;
|
||||
if (prop === "toJSON") {
|
||||
return () => target;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
has(target, prop) {
|
||||
if (_props.includes(prop as string)) {
|
||||
return true;
|
||||
}
|
||||
return Reflect.has(target, prop);
|
||||
},
|
||||
ownKeys(target) {
|
||||
return Array.from(new Set([...Reflect.ownKeys(target), ..._props]));
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (_props.includes(prop as string)) {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: Reflect.get({ raw, body, ok: raw.ok, status: raw.status }, prop)
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, prop);
|
||||
}
|
||||
}) as ResponseObject<Body, Data>;
|
||||
}
|
||||
|
||||
export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
|
||||
// @ts-ignore
|
||||
[Symbol.toStringTag]: "FetchPromise";
|
||||
|
||||
constructor(
|
||||
public request: Request,
|
||||
protected options?: {
|
||||
fetcher?: typeof fetch;
|
||||
}
|
||||
) {}
|
||||
|
||||
async execute(): Promise<ResponseObject<T>> {
|
||||
// delay in dev environment
|
||||
isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
|
||||
const fetcher = this.options?.fetcher ?? fetch;
|
||||
const res = await fetcher(this.request);
|
||||
let resBody: any;
|
||||
let resData: any;
|
||||
|
||||
@@ -99,69 +221,51 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions> {
|
||||
resBody = await res.text();
|
||||
}
|
||||
|
||||
return {
|
||||
success: res.ok,
|
||||
status: res.status,
|
||||
body: resBody,
|
||||
data: resData,
|
||||
res
|
||||
};
|
||||
return createResponseProxy<T>(res, resBody, resData);
|
||||
}
|
||||
|
||||
protected async get<Data = any>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
_query?: Record<string, any> | URLSearchParams,
|
||||
_init?: RequestInit
|
||||
) {
|
||||
return this.request<Data>(_input, _query, {
|
||||
..._init,
|
||||
method: "GET"
|
||||
});
|
||||
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
|
||||
then<TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null | undefined,
|
||||
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null | undefined
|
||||
): Promise<TResult1 | TResult2> {
|
||||
return this.execute().then(onfulfilled as any, onrejected);
|
||||
}
|
||||
|
||||
protected async post<Data = any>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
body?: any,
|
||||
_init?: RequestInit
|
||||
) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
body,
|
||||
method: "POST"
|
||||
});
|
||||
catch<TResult = never>(
|
||||
onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | null | undefined
|
||||
): Promise<T | TResult> {
|
||||
return this.then(undefined, onrejected);
|
||||
}
|
||||
|
||||
protected async patch<Data = any>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
body?: any,
|
||||
_init?: RequestInit
|
||||
) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
body,
|
||||
method: "PATCH"
|
||||
});
|
||||
finally(onfinally?: (() => void) | null | undefined): Promise<T> {
|
||||
return this.then(
|
||||
(value) => {
|
||||
onfinally?.();
|
||||
return value;
|
||||
},
|
||||
(reason) => {
|
||||
onfinally?.();
|
||||
throw reason;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected async put<Data = any>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
body?: any,
|
||||
_init?: RequestInit
|
||||
) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
body,
|
||||
method: "PUT"
|
||||
});
|
||||
path(): string {
|
||||
const url = new URL(this.request.url);
|
||||
return url.pathname;
|
||||
}
|
||||
|
||||
protected async delete<Data = any>(
|
||||
_input: string | (string | number | PrimaryFieldType)[],
|
||||
_init?: RequestInit
|
||||
) {
|
||||
return this.request<Data>(_input, undefined, {
|
||||
..._init,
|
||||
method: "DELETE"
|
||||
});
|
||||
key(options?: { search: boolean }): string {
|
||||
const url = new URL(this.request.url);
|
||||
return options?.search !== false ? this.path() + url.search : this.path();
|
||||
}
|
||||
|
||||
keyArray(options?: { search: boolean }): string[] {
|
||||
const url = new URL(this.request.url);
|
||||
const path = this.path().split("/");
|
||||
return (options?.search !== false ? [...path, url.searchParams.toString()] : path).filter(
|
||||
Boolean
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Guard } from "auth";
|
||||
import { BkndError, DebugLogger, Exception, isDebug } from "core";
|
||||
import { BkndError, DebugLogger } from "core";
|
||||
import { EventManager } from "core/events";
|
||||
import { clone, diff } from "core/object/diff";
|
||||
import {
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
type Static,
|
||||
StringEnum,
|
||||
Type,
|
||||
mark,
|
||||
objectEach,
|
||||
stripMark,
|
||||
transformObject
|
||||
transformObject,
|
||||
withDisabledConsole
|
||||
} from "core/utils";
|
||||
import {
|
||||
type Connection,
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
} from "data";
|
||||
import { TransformPersistFailedException } from "data/errors";
|
||||
import { Hono } from "hono";
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import type { Kysely } from "kysely";
|
||||
import { mergeWith } from "lodash-es";
|
||||
import { CURRENT_VERSION, TABLE_NAME, migrate } from "modules/migrations";
|
||||
import { AppServer } from "modules/server/AppServer";
|
||||
@@ -31,11 +33,13 @@ import { AppAuth } from "../auth/AppAuth";
|
||||
import { AppData } from "../data/AppData";
|
||||
import { AppFlows } from "../flows/AppFlows";
|
||||
import { AppMedia } from "../media/AppMedia";
|
||||
import type { Module, ModuleBuildContext } from "./Module";
|
||||
import { Module, type ModuleBuildContext, type ServerEnv } from "./Module";
|
||||
|
||||
export type { ModuleBuildContext };
|
||||
|
||||
export const MODULES = {
|
||||
server: AppServer,
|
||||
data: AppData<any>,
|
||||
data: AppData,
|
||||
auth: AppAuth,
|
||||
media: AppMedia,
|
||||
flows: AppFlows
|
||||
@@ -71,8 +75,16 @@ export type ModuleManagerOptions = {
|
||||
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
|
||||
trustFetched?: boolean;
|
||||
// runs when initial config provided on a fresh database
|
||||
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||
};
|
||||
|
||||
type ConfigTable<Json = ModuleConfigs> = {
|
||||
@@ -102,9 +114,9 @@ const __bknd = entity(TABLE_NAME, {
|
||||
updated_at: datetime()
|
||||
});
|
||||
type ConfigTable2 = Schema<typeof __bknd>;
|
||||
type T_INTERNAL_EM = {
|
||||
interface T_INTERNAL_EM {
|
||||
__bknd: ConfigTable2;
|
||||
};
|
||||
}
|
||||
|
||||
// @todo: cleanup old diffs on upgrade
|
||||
// @todo: cleanup multiple backups on upgrade
|
||||
@@ -113,16 +125,13 @@ export class ModuleManager {
|
||||
// internal em for __bknd config table
|
||||
__em!: EntityManager<T_INTERNAL_EM>;
|
||||
// ctx for modules
|
||||
em!: EntityManager<any>;
|
||||
server!: Hono;
|
||||
em!: EntityManager;
|
||||
server!: Hono<ServerEnv>;
|
||||
emgr!: EventManager;
|
||||
guard!: Guard;
|
||||
|
||||
private _version: number = 0;
|
||||
private _built = false;
|
||||
private _fetched = false;
|
||||
|
||||
// @todo: keep? not doing anything with it
|
||||
private readonly _booted_with?: "provided" | "partial";
|
||||
|
||||
private logger = new DebugLogger(false);
|
||||
@@ -187,23 +196,24 @@ export class ModuleManager {
|
||||
}
|
||||
|
||||
async syncConfigTable() {
|
||||
return await this.__em.schema().sync({ force: true });
|
||||
this.logger.context("sync").log("start");
|
||||
const result = await this.__em.schema().sync({ force: true });
|
||||
this.logger.log("done").clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
private rebuildServer() {
|
||||
this.server = new Hono();
|
||||
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);
|
||||
}
|
||||
|
||||
// @todo: this is a current workaround, controllers must be reworked
|
||||
// optional method for each module to register global middlewares, etc.
|
||||
objectEach(this.modules, (module) => {
|
||||
if ("getMiddleware" in module) {
|
||||
const middleware = module.getMiddleware();
|
||||
if (middleware) {
|
||||
this.server.use(middleware);
|
||||
}
|
||||
}
|
||||
module.onServerInit(this.server);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -219,26 +229,31 @@ export class ModuleManager {
|
||||
server: this.server,
|
||||
em: this.em,
|
||||
emgr: this.emgr,
|
||||
guard: this.guard
|
||||
guard: this.guard,
|
||||
flags: Module.ctx_flags
|
||||
};
|
||||
}
|
||||
|
||||
private async fetch(): Promise<ConfigTable> {
|
||||
this.logger.context("fetch").log("fetching");
|
||||
|
||||
const startTime = performance.now();
|
||||
const { data: result } = await this.repo().findOne(
|
||||
{ type: "config" },
|
||||
{
|
||||
sort: { by: "version", dir: "desc" }
|
||||
}
|
||||
);
|
||||
if (!result) {
|
||||
throw BkndError.with("no config");
|
||||
}
|
||||
// disabling console log, because the table might not exist yet
|
||||
return await withDisabledConsole(async () => {
|
||||
const startTime = performance.now();
|
||||
const { data: result } = await this.repo().findOne(
|
||||
{ type: "config" },
|
||||
{
|
||||
sort: { by: "version", dir: "desc" }
|
||||
}
|
||||
);
|
||||
|
||||
this.logger.log("took", performance.now() - startTime, "ms", result).clear();
|
||||
return result as ConfigTable;
|
||||
if (!result) {
|
||||
throw BkndError.with("no config");
|
||||
}
|
||||
|
||||
this.logger.log("took", performance.now() - startTime, "ms", result.version).clear();
|
||||
return result as ConfigTable;
|
||||
}, ["log", "error", "warn"]);
|
||||
}
|
||||
|
||||
async save() {
|
||||
@@ -284,7 +299,7 @@ export class ModuleManager {
|
||||
version,
|
||||
json: configs,
|
||||
updated_at: new Date()
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
type: "config",
|
||||
version
|
||||
@@ -359,9 +374,6 @@ export class ModuleManager {
|
||||
configs = _configs;
|
||||
|
||||
this.setConfigs(configs);
|
||||
/* objectEach(configs, (config, key) => {
|
||||
this.get(key as any).setConfig(config);
|
||||
}); */
|
||||
|
||||
this._version = version;
|
||||
this.logger.log("migrated to", version);
|
||||
@@ -388,32 +400,48 @@ export class ModuleManager {
|
||||
});
|
||||
}
|
||||
|
||||
private async buildModules(options?: { graceful?: boolean }) {
|
||||
this.logger.log("buildModules() triggered", options?.graceful, this._built);
|
||||
private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
|
||||
this.logger.log("buildModules() triggered", options, this._built);
|
||||
if (options?.graceful && this._built) {
|
||||
this.logger.log("skipping build (graceful)");
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log("building");
|
||||
const ctx = this.ctx(true);
|
||||
for (const key in this.modules) {
|
||||
this.logger.log(`building "${key}"`);
|
||||
await this.modules[key].setContext(ctx).build();
|
||||
this.logger.log("built", key);
|
||||
}
|
||||
|
||||
this._built = true;
|
||||
this.logger.log("modules built");
|
||||
this.logger.log("modules built", ctx.flags);
|
||||
|
||||
if (options?.ignoreFlags !== true) {
|
||||
if (ctx.flags.sync_required) {
|
||||
ctx.flags.sync_required = false;
|
||||
this.logger.log("db sync requested");
|
||||
|
||||
// sync db
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await this.save();
|
||||
}
|
||||
|
||||
if (ctx.flags.ctx_reload_required) {
|
||||
ctx.flags.ctx_reload_required = false;
|
||||
this.logger.log("ctx reload requested");
|
||||
this.ctx(true);
|
||||
}
|
||||
}
|
||||
|
||||
// reset all falgs
|
||||
ctx.flags = Module.ctx_flags;
|
||||
}
|
||||
|
||||
async build() {
|
||||
this.logger.context("build").log("version", this.version());
|
||||
this.logger.log("booted with", this._booted_with);
|
||||
|
||||
// @todo: check this, because you could start without an initial config
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
await this.syncConfigTable();
|
||||
}
|
||||
|
||||
// if no config provided, try fetch from db
|
||||
if (this.version() === 0) {
|
||||
this.logger.context("no version").log("version is 0");
|
||||
@@ -422,6 +450,16 @@ export class ModuleManager {
|
||||
|
||||
// set version and config from fetched
|
||||
this._version = result.version;
|
||||
|
||||
if (this.version() !== CURRENT_VERSION) {
|
||||
await this.syncConfigTable();
|
||||
}
|
||||
|
||||
if (this.options?.trustFetched === true) {
|
||||
this.logger.log("trusting fetched config (mark)");
|
||||
mark(result.json);
|
||||
}
|
||||
|
||||
this.setConfigs(result.json);
|
||||
} catch (e: any) {
|
||||
this.logger.clear(); // fetch couldn't clear
|
||||
@@ -431,9 +469,13 @@ export class ModuleManager {
|
||||
// we can safely build modules, since config version is up to date
|
||||
// it's up to date because we use default configs (no fetch result)
|
||||
this._version = CURRENT_VERSION;
|
||||
await this.syncConfigTable();
|
||||
await this.buildModules();
|
||||
await this.save();
|
||||
|
||||
// run initial setup
|
||||
await this.setupInitial();
|
||||
|
||||
this.logger.clear();
|
||||
return this;
|
||||
}
|
||||
@@ -448,6 +490,21 @@ export class ModuleManager {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected async setupInitial() {
|
||||
const ctx = {
|
||||
...this.ctx(),
|
||||
// disable events for initial setup
|
||||
em: this.ctx().em.fork()
|
||||
};
|
||||
|
||||
// perform a sync
|
||||
await ctx.em.schema().sync({ force: true });
|
||||
await this.options?.seed?.(ctx);
|
||||
|
||||
// run first boot event
|
||||
await this.options?.onFirstBoot?.();
|
||||
}
|
||||
|
||||
get<K extends keyof Modules>(key: K): Modules[K] {
|
||||
if (!(key in this.modules)) {
|
||||
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ConfigUpdateResponse } from "modules/server/SystemController";
|
||||
import { ModuleApi } from "./ModuleApi";
|
||||
import type { ModuleConfigs, ModuleKey, ModuleSchemas } from "./ModuleManager";
|
||||
|
||||
@@ -15,37 +16,41 @@ export class SystemApi extends ModuleApi<any> {
|
||||
};
|
||||
}
|
||||
|
||||
async readSchema(options?: { config?: boolean; secrets?: boolean }) {
|
||||
return await this.get<ApiSchemaResponse>("schema", {
|
||||
readConfig() {
|
||||
return this.get<{ version: number } & ModuleConfigs>("config");
|
||||
}
|
||||
|
||||
readSchema(options?: { config?: boolean; secrets?: boolean }) {
|
||||
return this.get<ApiSchemaResponse>("schema", {
|
||||
config: options?.config ? 1 : 0,
|
||||
secrets: options?.secrets ? 1 : 0
|
||||
});
|
||||
}
|
||||
|
||||
async setConfig<Module extends ModuleKey>(
|
||||
setConfig<Module extends ModuleKey>(
|
||||
module: Module,
|
||||
value: ModuleConfigs[Module],
|
||||
force?: boolean
|
||||
) {
|
||||
return await this.post<any>(
|
||||
return this.post<ConfigUpdateResponse>(
|
||||
["config", "set", module].join("/") + `?force=${force ? 1 : 0}`,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
async addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||
return await this.post<any>(["config", "add", module, path], value);
|
||||
addConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||
return this.post<ConfigUpdateResponse>(["config", "add", module, path], value);
|
||||
}
|
||||
|
||||
async patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||
return await this.patch<any>(["config", "patch", module, path], value);
|
||||
patchConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||
return this.patch<ConfigUpdateResponse>(["config", "patch", module, path], value);
|
||||
}
|
||||
|
||||
async overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||
return await this.put<any>(["config", "overwrite", module, path], value);
|
||||
overwriteConfig<Module extends ModuleKey>(module: Module, path: string, value: any) {
|
||||
return this.put<ConfigUpdateResponse>(["config", "overwrite", module, path], value);
|
||||
}
|
||||
|
||||
async removeConfig<Module extends ModuleKey>(module: Module, path: string) {
|
||||
return await this.delete<any>(["config", "remove", module, path]);
|
||||
removeConfig<Module extends ModuleKey>(module: Module, path: string) {
|
||||
return this.delete<ConfigUpdateResponse>(["config", "remove", module, path]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export {
|
||||
MODULE_NAMES,
|
||||
type ModuleKey
|
||||
} from "./ModuleManager";
|
||||
export { /*Module,*/ type ModuleBuildContext } from "./Module";
|
||||
export type { ModuleBuildContext } from "./Module";
|
||||
|
||||
export {
|
||||
type PrimaryFieldType,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { auth, permission } from "auth/middlewares";
|
||||
@@ -1,11 +1,11 @@
|
||||
/** @jsxImportSource hono/jsx */
|
||||
|
||||
import type { App } from "App";
|
||||
import { type ClassController, isDebug } from "core";
|
||||
import { config, isDebug } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import { Hono } from "hono";
|
||||
import { html } from "hono/html";
|
||||
import { Fragment } from "hono/jsx";
|
||||
import { Controller } from "modules/Controller";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
|
||||
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||
@@ -13,38 +13,52 @@ const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||
// @todo: add migration to remove admin path from config
|
||||
export type AdminControllerOptions = {
|
||||
basepath?: string;
|
||||
assets_path?: string;
|
||||
html?: string;
|
||||
forceDev?: boolean | { mainPath: string };
|
||||
};
|
||||
|
||||
export class AdminController implements ClassController {
|
||||
export class AdminController extends Controller {
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private options: AdminControllerOptions = {}
|
||||
) {}
|
||||
private _options: AdminControllerOptions = {}
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get ctx() {
|
||||
return this.app.modules.ctx();
|
||||
}
|
||||
|
||||
get options() {
|
||||
return {
|
||||
...this._options,
|
||||
basepath: this._options.basepath ?? "/",
|
||||
assets_path: this._options.assets_path ?? config.server.assets_path
|
||||
};
|
||||
}
|
||||
|
||||
get basepath() {
|
||||
return this.options.basepath ?? "/";
|
||||
}
|
||||
|
||||
private withBasePath(route: string = "") {
|
||||
return (this.basepath + route).replace(/\/+$/, "/");
|
||||
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||
}
|
||||
|
||||
getController(): Hono<any> {
|
||||
override getController() {
|
||||
const { auth: authMiddleware, permission } = this.middlewares;
|
||||
const hono = this.create().use(
|
||||
authMiddleware({
|
||||
//skip: [/favicon\.ico$/]
|
||||
})
|
||||
);
|
||||
|
||||
const auth = this.app.module.auth;
|
||||
const configs = this.app.modules.configs();
|
||||
// if auth is not enabled, authenticator is undefined
|
||||
const auth_enabled = configs.auth.enabled;
|
||||
const hono = new Hono<{
|
||||
Variables: {
|
||||
html: string;
|
||||
};
|
||||
}>().basePath(this.withBasePath());
|
||||
|
||||
const authRoutes = {
|
||||
root: "/",
|
||||
success: configs.auth.cookie.pathSuccess ?? "/",
|
||||
@@ -66,23 +80,26 @@ export class AdminController implements ClassController {
|
||||
}
|
||||
c.set("html", html);
|
||||
|
||||
// refresh cookie if needed
|
||||
await auth.authenticator?.requestCookieRefresh(c);
|
||||
await next();
|
||||
});
|
||||
|
||||
if (auth_enabled) {
|
||||
hono.get(authRoutes.login, async (c) => {
|
||||
if (
|
||||
this.app.module.auth.authenticator?.isUserLoggedIn() &&
|
||||
this.ctx.guard.granted(SystemPermissions.accessAdmin)
|
||||
) {
|
||||
return c.redirect(authRoutes.success);
|
||||
hono.get(
|
||||
authRoutes.login,
|
||||
permission([SystemPermissions.accessAdmin, SystemPermissions.schemaRead], {
|
||||
// @ts-ignore
|
||||
onGranted: async (c) => {
|
||||
// @todo: add strict test to permissions middleware?
|
||||
if (auth.authenticator.isUserLoggedIn()) {
|
||||
console.log("redirecting to success");
|
||||
return c.redirect(authRoutes.success);
|
||||
}
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.html(c.get("html")!);
|
||||
}
|
||||
|
||||
const html = c.get("html");
|
||||
return c.html(html);
|
||||
});
|
||||
);
|
||||
|
||||
hono.get(authRoutes.logout, async (c) => {
|
||||
await auth.authenticator?.logout(c);
|
||||
@@ -90,15 +107,26 @@ export class AdminController implements ClassController {
|
||||
});
|
||||
}
|
||||
|
||||
hono.get("*", async (c) => {
|
||||
if (!this.ctx.guard.granted(SystemPermissions.accessAdmin)) {
|
||||
await addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||
return c.redirect(authRoutes.login);
|
||||
}
|
||||
// @todo: only load known paths
|
||||
hono.get(
|
||||
"/*",
|
||||
permission(SystemPermissions.accessAdmin, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You are not authorized to access the Admin UI", "error");
|
||||
|
||||
const html = c.get("html");
|
||||
return c.html(html);
|
||||
});
|
||||
console.log("redirecting");
|
||||
return c.redirect(authRoutes.login);
|
||||
}
|
||||
}),
|
||||
permission(SystemPermissions.schemaRead, {
|
||||
onDenied: async (c) => {
|
||||
addFlashMessage(c, "You not allowed to read the schema", "warning");
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.html(c.get("html")!);
|
||||
}
|
||||
);
|
||||
|
||||
return hono;
|
||||
}
|
||||
@@ -138,29 +166,42 @@ export class AdminController implements ClassController {
|
||||
const manifest = await import("bknd/dist/manifest.json", {
|
||||
assert: { type: "json" }
|
||||
}).then((m) => m.default);
|
||||
assets.js = manifest["src/ui/main.tsx"].name;
|
||||
assets.css = manifest["src/ui/main.css"].name;
|
||||
// @todo: load all marked as entry (incl. css)
|
||||
assets.js = manifest["src/ui/main.tsx"].file;
|
||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||
} catch (e) {
|
||||
console.error("Error loading manifest", e);
|
||||
}
|
||||
}
|
||||
|
||||
const theme = configs.server.admin.color_scheme ?? "light";
|
||||
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{/* dnd complains otherwise */}
|
||||
{html`<!DOCTYPE html>`}
|
||||
<html lang="en" class={configs.server.admin.color_scheme ?? "light"}>
|
||||
<html lang="en" class={theme}>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="icon" href={favicon} type="image/x-icon" />
|
||||
<title>BKND</title>
|
||||
{isProd ? (
|
||||
<Fragment>
|
||||
<script type="module" CrossOrigin src={"/" + assets?.js} />
|
||||
<link rel="stylesheet" crossOrigin href={"/" + assets?.css} />
|
||||
<script
|
||||
type="module"
|
||||
CrossOrigin
|
||||
src={this.options.assets_path + assets?.js}
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
crossOrigin
|
||||
href={this.options.assets_path + assets?.css}
|
||||
/>
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
@@ -177,10 +218,16 @@ export class AdminController implements ClassController {
|
||||
<script type="module" src={"/@vite/client"} />
|
||||
</Fragment>
|
||||
)}
|
||||
<style dangerouslySetInnerHTML={{ __html: "body { margin: 0; padding: 0; }" }} />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" />
|
||||
<div id="app" />
|
||||
<div id="root">
|
||||
<div id="loading" style={style(theme)}>
|
||||
<span style={{ opacity: 0.3, fontSize: 14, fontFamily: "monospace" }}>
|
||||
Initializing...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: bknd_context
|
||||
@@ -193,3 +240,32 @@ export class AdminController implements ClassController {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const style = (theme: "light" | "dark" = "light") => {
|
||||
const base = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
"-webkit-font-smoothing": "antialiased",
|
||||
"-moz-osx-font-smoothing": "grayscale"
|
||||
};
|
||||
const styles = {
|
||||
light: {
|
||||
color: "rgb(9,9,11)",
|
||||
backgroundColor: "rgb(250,250,250)"
|
||||
},
|
||||
dark: {
|
||||
color: "rgb(250,250,250)",
|
||||
backgroundColor: "rgb(30,31,34)"
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
...styles[theme]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -74,6 +74,21 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
})
|
||||
);
|
||||
|
||||
// add an initial fallback route
|
||||
this.client.use("/", async (c, next) => {
|
||||
await next();
|
||||
// if not finalized or giving a 404
|
||||
if (!c.finalized || c.res.status === 404) {
|
||||
// double check it's root
|
||||
if (new URL(c.req.url).pathname === "/") {
|
||||
c.res = undefined;
|
||||
c.res = Response.json({
|
||||
bknd: "hello world!"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.client.onError((err, c) => {
|
||||
//throw err;
|
||||
console.error(err);
|
||||
@@ -82,21 +97,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
return err;
|
||||
}
|
||||
|
||||
/*if (isDebug()) {
|
||||
console.log("accept", c.req.header("Accept"));
|
||||
if (c.req.header("Accept") === "application/json") {
|
||||
const stack = err.stack;
|
||||
|
||||
if ("toJSON" in err && typeof err.toJSON === "function") {
|
||||
return c.json({ ...err.toJSON(), stack }, 500);
|
||||
}
|
||||
|
||||
return c.json({ message: String(err), stack }, 500);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}*/
|
||||
|
||||
if (err instanceof Exception) {
|
||||
console.log("---is exception", err.code);
|
||||
return c.json(err.toJSON(), err.code as any);
|
||||
@@ -107,32 +107,6 @@ export class AppServer extends Module<typeof serverConfigSchema> {
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
/*setAdminHtml(html: string) {
|
||||
this.admin_html = html;
|
||||
const basepath = (String(this.config.admin.basepath) + "/").replace(/\/+$/, "/");
|
||||
|
||||
const allowed_prefix = basepath + "auth";
|
||||
const login_path = basepath + "auth/login";
|
||||
|
||||
this.client.get(basepath + "*", async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (!path.startsWith(allowed_prefix)) {
|
||||
console.log("guard check permissions");
|
||||
try {
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.admin);
|
||||
} catch (e) {
|
||||
return c.redirect(login_path);
|
||||
}
|
||||
}
|
||||
|
||||
return c.html(this.admin_html!);
|
||||
});
|
||||
}
|
||||
|
||||
getAdminHtml() {
|
||||
return this.admin_html;
|
||||
}*/
|
||||
|
||||
override toJSON(secrets?: boolean) {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import type { ClassController } from "core";
|
||||
import type { App } from "App";
|
||||
import { tbValidator as tb } from "core";
|
||||
import { StringEnum, Type, TypeInvalidError } from "core/utils";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { MODULE_NAMES, type ModuleKey, getDefaultConfig } from "modules/ModuleManager";
|
||||
import { getRuntimeKey } from "core/utils";
|
||||
import type { Context, Hono } from "hono";
|
||||
import { Controller } from "modules/Controller";
|
||||
|
||||
import {
|
||||
MODULE_NAMES,
|
||||
type ModuleConfigs,
|
||||
type ModuleKey,
|
||||
getDefaultConfig
|
||||
} from "modules/ModuleManager";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
import { generateOpenAPI } from "modules/server/openapi";
|
||||
import type { App } from "../../App";
|
||||
|
||||
const booleanLike = Type.Transform(Type.String())
|
||||
.Decode((v) => v === "1")
|
||||
.Encode((v) => (v ? "1" : "0"));
|
||||
|
||||
export class SystemController implements ClassController {
|
||||
constructor(private readonly app: App) {}
|
||||
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
|
||||
success: true;
|
||||
module: Key;
|
||||
config: ModuleConfigs[Key];
|
||||
};
|
||||
export type ConfigUpdateResponse<Key extends ModuleKey = ModuleKey> =
|
||||
| ConfigUpdate<Key>
|
||||
| { success: false; type: "type-invalid" | "error" | "unknown"; error?: any; errors?: any };
|
||||
|
||||
export class SystemController extends Controller {
|
||||
constructor(private readonly app: App) {
|
||||
super();
|
||||
}
|
||||
|
||||
get ctx() {
|
||||
return this.app.modules.ctx();
|
||||
}
|
||||
|
||||
private registerConfigController(client: Hono<any>): void {
|
||||
const hono = new Hono();
|
||||
const { permission } = this.middlewares;
|
||||
const hono = this.create();
|
||||
|
||||
/*hono.use("*", async (c, next) => {
|
||||
//this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
||||
console.log("perm?", this.ctx.guard.hasPermission(SystemPermissions.configRead));
|
||||
return next();
|
||||
});*/
|
||||
hono.use(permission(SystemPermissions.configRead));
|
||||
|
||||
hono.get(
|
||||
"/:module?",
|
||||
@@ -43,7 +58,6 @@ export class SystemController implements ClassController {
|
||||
const { secrets } = c.req.valid("query");
|
||||
const { module } = c.req.valid("param");
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||
|
||||
const config = this.app.toJSON(secrets);
|
||||
@@ -60,7 +74,7 @@ export class SystemController implements ClassController {
|
||||
}
|
||||
);
|
||||
|
||||
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<object>) {
|
||||
async function handleConfigUpdateResponse(c: Context<any>, cb: () => Promise<ConfigUpdate>) {
|
||||
try {
|
||||
return c.json(await cb(), { status: 202 });
|
||||
} catch (e) {
|
||||
@@ -82,6 +96,7 @@ export class SystemController implements ClassController {
|
||||
|
||||
hono.post(
|
||||
"/set/:module",
|
||||
permission(SystemPermissions.configWrite),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
@@ -93,8 +108,6 @@ export class SystemController implements ClassController {
|
||||
const { force } = c.req.valid("query");
|
||||
const value = await c.req.json();
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
// you must explicitly set force to override existing values
|
||||
// because omitted values gets removed
|
||||
@@ -117,14 +130,12 @@ export class SystemController implements ClassController {
|
||||
}
|
||||
);
|
||||
|
||||
hono.post("/add/:module/:path", async (c) => {
|
||||
hono.post("/add/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path") as string;
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
||||
|
||||
const moduleConfig = this.app.mutateConfig(module);
|
||||
if (moduleConfig.has(path)) {
|
||||
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
|
||||
@@ -141,14 +152,12 @@ export class SystemController implements ClassController {
|
||||
});
|
||||
});
|
||||
|
||||
hono.patch("/patch/:module/:path", async (c) => {
|
||||
hono.patch("/patch/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path");
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await this.app.mutateConfig(module).patch(path, value);
|
||||
return {
|
||||
@@ -159,14 +168,12 @@ export class SystemController implements ClassController {
|
||||
});
|
||||
});
|
||||
|
||||
hono.put("/overwrite/:module/:path", async (c) => {
|
||||
hono.put("/overwrite/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path");
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await this.app.mutateConfig(module).overwrite(path, value);
|
||||
return {
|
||||
@@ -177,13 +184,11 @@ export class SystemController implements ClassController {
|
||||
});
|
||||
});
|
||||
|
||||
hono.delete("/remove/:module/:path", async (c) => {
|
||||
hono.delete("/remove/:module/:path", permission(SystemPermissions.configWrite), async (c) => {
|
||||
// @todo: require auth (admin)
|
||||
const module = c.req.param("module") as any;
|
||||
const path = c.req.param("path")!;
|
||||
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.configWrite);
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await this.app.mutateConfig(module).remove(path);
|
||||
return {
|
||||
@@ -197,13 +202,15 @@ export class SystemController implements ClassController {
|
||||
client.route("/config", hono);
|
||||
}
|
||||
|
||||
getController(): Hono {
|
||||
const hono = new Hono();
|
||||
override getController() {
|
||||
const { permission, auth } = this.middlewares;
|
||||
const hono = this.create().use(auth());
|
||||
|
||||
this.registerConfigController(hono);
|
||||
|
||||
hono.get(
|
||||
"/schema/:module?",
|
||||
permission(SystemPermissions.schemaRead),
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
@@ -214,7 +221,7 @@ export class SystemController implements ClassController {
|
||||
async (c) => {
|
||||
const module = c.req.param("module") as ModuleKey | undefined;
|
||||
const { config, secrets } = c.req.valid("query");
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.schemaRead);
|
||||
|
||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||
|
||||
@@ -286,8 +293,8 @@ export class SystemController implements ClassController {
|
||||
return c.json({
|
||||
version: this.app.version(),
|
||||
test: 2,
|
||||
// @ts-ignore
|
||||
app: !!c.var.app
|
||||
app: c.get("app")?.version(),
|
||||
runtime: getRuntimeKey()
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+44
-1
@@ -3,6 +3,8 @@ import { Notifications } from "@mantine/notifications";
|
||||
import type { ModuleConfigs } from "modules";
|
||||
import React from "react";
|
||||
import { BkndProvider, useBknd } from "ui/client/bknd";
|
||||
import { Logo } from "ui/components/display/Logo";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { FlashMessage } from "ui/modules/server/FlashMessage";
|
||||
import { ClientProvider, type ClientProviderProps } from "./client";
|
||||
import { createMantineTheme } from "./lib/mantine/theme";
|
||||
@@ -21,7 +23,7 @@ export default function Admin({
|
||||
config
|
||||
}: BkndAdminProps) {
|
||||
const Component = (
|
||||
<BkndProvider adminOverride={config}>
|
||||
<BkndProvider adminOverride={config} fallback={<Skeleton theme={config?.color_scheme} />}>
|
||||
<AdminInternal />
|
||||
</BkndProvider>
|
||||
);
|
||||
@@ -51,3 +53,44 @@ function AdminInternal() {
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const Skeleton = ({ theme }: { theme?: string }) => {
|
||||
const actualTheme =
|
||||
(theme ?? document.querySelector("html")?.classList.contains("light")) ? "light" : "dark";
|
||||
|
||||
return (
|
||||
<div id="bknd-admin" className={actualTheme + " antialiased"}>
|
||||
<AppShell.Root>
|
||||
<header
|
||||
data-shell="header"
|
||||
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
|
||||
>
|
||||
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
|
||||
<Logo theme={actualTheme} />
|
||||
</div>
|
||||
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
|
||||
{[...new Array(5)].map((item, key) => (
|
||||
<AppShell.NavLink key={key} as="span" className="active h-full opacity-50">
|
||||
<div className="w-10 h-3" />
|
||||
</AppShell.NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<nav className="flex md:hidden flex-row items-center">
|
||||
<AppShell.NavLink as="span" className="active h-full opacity-50">
|
||||
<div className="w-10 h-3" />
|
||||
</AppShell.NavLink>
|
||||
</nav>
|
||||
<div className="flex flex-grow" />
|
||||
<div className="hidden lg:flex flex-row items-center px-4 gap-2 opacity-50">
|
||||
<div className="size-11 rounded-full bg-primary/10" />
|
||||
</div>
|
||||
</header>
|
||||
<AppShell.Content>
|
||||
<div className="flex flex-col w-full h-full justify-center items-center">
|
||||
{/*<span className="font-mono opacity-30">Loading</span>*/}
|
||||
</div>
|
||||
</AppShell.Content>
|
||||
</AppShell.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ModuleConfigs, ModuleSchemas } from "modules";
|
||||
import { getDefaultConfig, getDefaultSchema } from "modules/ModuleManager";
|
||||
import { createContext, startTransition, useContext, useEffect, useRef, useState } from "react";
|
||||
import type { ModuleConfigs, ModuleSchemas } from "../../modules";
|
||||
import { useClient } from "./ClientProvider";
|
||||
import { useApi } from "ui/client";
|
||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||
import { AppReduced } from "./utils/AppReduced";
|
||||
|
||||
@@ -22,14 +22,18 @@ export type { TSchemaActions };
|
||||
export function BkndProvider({
|
||||
includeSecrets = false,
|
||||
adminOverride,
|
||||
children
|
||||
}: { includeSecrets?: boolean; children: any } & Pick<BkndContext, "adminOverride">) {
|
||||
children,
|
||||
fallback = null
|
||||
}: { includeSecrets?: boolean; children: any; fallback?: React.ReactNode } & Pick<
|
||||
BkndContext,
|
||||
"adminOverride"
|
||||
>) {
|
||||
const [withSecrets, setWithSecrets] = useState<boolean>(includeSecrets);
|
||||
const [schema, setSchema] =
|
||||
useState<Pick<BkndContext, "version" | "schema" | "config" | "permissions">>();
|
||||
const [fetched, setFetched] = useState(false);
|
||||
const errorShown = useRef<boolean>();
|
||||
const client = useClient();
|
||||
const api = useApi();
|
||||
|
||||
async function reloadSchema() {
|
||||
await fetchSchema(includeSecrets, true);
|
||||
@@ -37,7 +41,7 @@ export function BkndProvider({
|
||||
|
||||
async function fetchSchema(_includeSecrets: boolean = false, force?: boolean) {
|
||||
if (withSecrets && !force) return;
|
||||
const { body, res } = await client.api.system.readSchema({
|
||||
const res = await api.system.readSchema({
|
||||
config: true,
|
||||
secrets: _includeSecrets
|
||||
});
|
||||
@@ -57,7 +61,7 @@ export function BkndProvider({
|
||||
}
|
||||
|
||||
const schema = res.ok
|
||||
? body
|
||||
? res.body
|
||||
: ({
|
||||
version: 0,
|
||||
schema: getDefaultSchema(),
|
||||
@@ -89,9 +93,9 @@ export function BkndProvider({
|
||||
fetchSchema(includeSecrets);
|
||||
}, []);
|
||||
|
||||
if (!fetched || !schema) return null;
|
||||
if (!fetched || !schema) return fallback;
|
||||
const app = new AppReduced(schema?.config as any);
|
||||
const actions = getSchemaActions({ client, setSchema, reloadSchema });
|
||||
const actions = getSchemaActions({ api, setSchema, reloadSchema });
|
||||
|
||||
return (
|
||||
<BkndContext.Provider value={{ ...schema, actions, requireSecrets, app, adminOverride }}>
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { TApiUser } from "Api";
|
||||
import { Api, type ApiOptions, type TApiUser } from "Api";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
//import { useBkndWindowContext } from "ui/client/BkndProvider";
|
||||
import { AppQueryClient } from "./utils/AppQueryClient";
|
||||
|
||||
const ClientContext = createContext<{ baseUrl: string; client: AppQueryClient }>({
|
||||
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
|
||||
baseUrl: undefined
|
||||
} as any);
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ClientProviderProps = {
|
||||
children?: any;
|
||||
baseUrl?: string;
|
||||
@@ -24,74 +12,53 @@ export type ClientProviderProps = {
|
||||
};
|
||||
|
||||
export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps) => {
|
||||
const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||
//const [actualBaseUrl, setActualBaseUrl] = useState<string | null>(null);
|
||||
const winCtx = useBkndWindowContext();
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
let actualBaseUrl = baseUrl ?? _ctx_baseUrl ?? "";
|
||||
|
||||
try {
|
||||
const _ctx_baseUrl = useBaseUrl();
|
||||
if (_ctx_baseUrl) {
|
||||
console.warn("wrapped many times");
|
||||
setActualBaseUrl(_ctx_baseUrl);
|
||||
if (!baseUrl) {
|
||||
if (_ctx_baseUrl) {
|
||||
actualBaseUrl = _ctx_baseUrl;
|
||||
console.warn("wrapped many times, take from context", actualBaseUrl);
|
||||
} else if (typeof window !== "undefined") {
|
||||
actualBaseUrl = window.location.origin;
|
||||
console.log("setting from window", actualBaseUrl);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error", e);
|
||||
console.error("error .....", e);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Only set base URL if running on the client side
|
||||
if (typeof window !== "undefined") {
|
||||
setActualBaseUrl(baseUrl || window.location.origin);
|
||||
}
|
||||
}, [baseUrl]);
|
||||
|
||||
if (!actualBaseUrl) {
|
||||
// Optionally, return a fallback during SSR rendering
|
||||
return null; // or a loader/spinner if desired
|
||||
}
|
||||
|
||||
//console.log("client provider11 with", { baseUrl, fallback: actualBaseUrl, user });
|
||||
const client = createClient(actualBaseUrl, user ?? winCtx.user);
|
||||
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ClientContext.Provider value={{ baseUrl: actualBaseUrl, client }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
</QueryClientProvider>
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
|
||||
{children}
|
||||
</ClientContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function createClient(baseUrl: string, user?: object) {
|
||||
return new AppQueryClient(baseUrl, user);
|
||||
}
|
||||
|
||||
export function createOrUseClient(baseUrl: string) {
|
||||
export const useApi = (host?: ApiOptions["host"]): Api => {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
console.warn("createOrUseClient returned a new client");
|
||||
return createClient(baseUrl);
|
||||
if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) {
|
||||
return new Api({ host: host ?? "" });
|
||||
}
|
||||
|
||||
return context.client;
|
||||
}
|
||||
|
||||
export const useClient = () => {
|
||||
const context = useContext(ClientContext);
|
||||
if (!context) {
|
||||
throw new Error("useClient must be used within a ClientProvider");
|
||||
}
|
||||
|
||||
console.log("useClient", context.baseUrl);
|
||||
return context.client;
|
||||
return context.api;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated use useApi().baseUrl instead
|
||||
*/
|
||||
export const useBaseUrl = () => {
|
||||
const context = useContext(ClientContext);
|
||||
return context.baseUrl;
|
||||
};
|
||||
|
||||
type BkndWindowContext = {
|
||||
user?: object;
|
||||
user?: TApiUser;
|
||||
logout_route: string;
|
||||
};
|
||||
export function useBkndWindowContext(): BkndWindowContext {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Api } from "Api";
|
||||
import type { FetchPromise, ResponseObject } from "modules/ModuleApi";
|
||||
import useSWR, { type SWRConfiguration, useSWRConfig } from "swr";
|
||||
import { useApi } from "ui/client";
|
||||
|
||||
export const useApiQuery = <
|
||||
Data,
|
||||
RefineFn extends (data: ResponseObject<Data>) => unknown = (data: ResponseObject<Data>) => Data
|
||||
>(
|
||||
fn: (api: Api) => FetchPromise<Data>,
|
||||
options?: SWRConfiguration & { enabled?: boolean; refine?: RefineFn }
|
||||
) => {
|
||||
const api = useApi();
|
||||
const promise = fn(api);
|
||||
const refine = options?.refine ?? ((data: any) => data);
|
||||
const fetcher = () => promise.execute().then(refine);
|
||||
const key = promise.key();
|
||||
|
||||
type RefinedData = RefineFn extends (data: ResponseObject<Data>) => infer R ? R : Data;
|
||||
|
||||
const swr = useSWR<RefinedData>(options?.enabled === false ? null : key, fetcher, options);
|
||||
return {
|
||||
...swr,
|
||||
promise,
|
||||
key,
|
||||
api
|
||||
};
|
||||
};
|
||||
|
||||
export const useInvalidate = () => {
|
||||
const mutate = useSWRConfig().mutate;
|
||||
const api = useApi();
|
||||
|
||||
return async (arg?: string | ((api: Api) => FetchPromise<any>)) => {
|
||||
if (!arg) return async () => mutate("");
|
||||
return mutate(typeof arg === "string" ? arg : arg(api).key());
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user