diff --git a/.gitignore b/.gitignore
index fe4c90f..3712ddf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ packages/media/.env
.history
**/*/.db/*
**/*/.configs/*
+**/*/.template/*
**/*/*.db
**/*/*.db-shm
**/*/*.db-wal
diff --git a/README.md b/README.md
index e2add07..fb5e81f 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,10 @@ bknd simplifies app development by providing a fully functional backend for data
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Size
-
-
-
-
+
+
+
+
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
diff --git a/app/__test__/_assets/.gitignore b/app/__test__/_assets/.gitignore
new file mode 100644
index 0000000..e540f5b
--- /dev/null
+++ b/app/__test__/_assets/.gitignore
@@ -0,0 +1 @@
+tmp/*
\ No newline at end of file
diff --git a/app/__test__/_assets/image.png b/app/__test__/_assets/image.png
new file mode 100644
index 0000000..2ebf8fc
Binary files /dev/null and b/app/__test__/_assets/image.png differ
diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts
index dbbe35d..c3c997a 100644
--- a/app/__test__/api/DataApi.spec.ts
+++ b/app/__test__/api/DataApi.spec.ts
@@ -17,11 +17,11 @@ describe("DataApi", () => {
const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
expect(get.request.method).toBe("GET");
- expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`);
+ expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`);
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
expect(post.request.method).toBe("POST");
- expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`);
+ expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`);
});
it("returns result", async () => {
@@ -39,7 +39,7 @@ describe("DataApi", () => {
const app = controller.getController();
{
- const res = (await app.request("/posts")) as Response;
+ const res = (await app.request("/entity/posts")) as Response;
const { data } = await res.json();
expect(data.length).toEqual(3);
}
diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts
new file mode 100644
index 0000000..27e86da
--- /dev/null
+++ b/app/__test__/api/MediaApi.spec.ts
@@ -0,0 +1,146 @@
+///
+import { describe, expect, it } from "bun:test";
+import { Hono } from "hono";
+import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
+import { MediaApi } from "../../src/media/api/MediaApi";
+import { assetsPath, assetsTmpPath } from "../helper";
+
+const mockedBackend = new Hono()
+ .basePath("/api/media")
+ .post("/upload/:name", async (c) => {
+ const { name } = c.req.param();
+ const body = await getFileFromContext(c);
+ return c.json({ name, is_file: isFile(body), size: body.size });
+ })
+ .get("/file/:name", async (c) => {
+ const { name } = c.req.param();
+ const file = Bun.file(`${assetsPath}/${name}`);
+ return new Response(file, {
+ headers: {
+ "Content-Type": file.type,
+ "Content-Length": file.size.toString()
+ }
+ });
+ });
+
+describe("MediaApi", () => {
+ it("should give correct file upload url", () => {
+ const host = "http://localhost";
+ const basepath = "/api/media";
+ // @ts-ignore tests
+ const api = new MediaApi({
+ host,
+ basepath
+ });
+ expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`);
+ });
+
+ it("should have correct upload headers", () => {
+ // @ts-ignore tests
+ const api = new MediaApi({
+ token: "token"
+ });
+ expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
+ });
+
+ it("should get file: native", async () => {
+ const name = "image.png";
+ const path = `${assetsTmpPath}/${name}`;
+ const res = await mockedBackend.request("/api/media/file/" + name);
+ await Bun.write(path, res);
+
+ const file = await Bun.file(path);
+ expect(file.size).toBeGreaterThan(0);
+ expect(file.type).toBe("image/png");
+ await file.delete();
+ });
+
+ it("download", async () => {
+ // @ts-ignore tests
+ const api = new MediaApi({}, mockedBackend.request);
+
+ const name = "image.png";
+ const file = await api.download(name);
+ expect(isFile(file)).toBe(true);
+ expect(file.size).toBeGreaterThan(0);
+ expect(file.type).toBe("image/png");
+ expect(file.name).toContain(name);
+ });
+
+ it("getFile", async () => {
+ // @ts-ignore tests
+ const api = new MediaApi({}, mockedBackend.request);
+
+ const name = "image.png";
+ const res = await api.getFile(name);
+ expect(res.ok).toBe(true);
+ // make sure it's a normal api request as usual
+ expect(res.res.ok).toBe(true);
+ expect(isReadableStream(res)).toBe(true);
+ expect(isReadableStream(res.body)).toBe(true);
+ expect(isReadableStream(res.res.body)).toBe(true);
+
+ const blob = await res.res.blob();
+ expect(isFile(blob)).toBe(true);
+ expect(blob.size).toBeGreaterThan(0);
+ expect(blob.type).toBe("image/png");
+ expect(blob.name).toContain(name);
+ });
+
+ it("getFileStream", async () => {
+ // @ts-ignore tests
+ const api = new MediaApi({}, mockedBackend.request);
+
+ const name = "image.png";
+ const res = await api.getFileStream(name);
+ expect(isReadableStream(res)).toBe(true);
+
+ const blob = await new Response(res).blob();
+ expect(isFile(blob)).toBe(true);
+ expect(blob.size).toBeGreaterThan(0);
+ expect(blob.type).toBe("image/png");
+ expect(blob.name).toContain(name);
+ });
+
+ it("should upload file in various ways", async () => {
+ // @ts-ignore tests
+ const api = new MediaApi({}, mockedBackend.request);
+ const file = Bun.file(`${assetsPath}/image.png`);
+
+ async function matches(req: Promise, filename: string) {
+ const res: any = await req;
+ expect(res.name).toBe(filename);
+ expect(res.is_file).toBe(true);
+ expect(res.size).toBe(file.size);
+ }
+
+ const url = "http://localhost/api/media/file/image.png";
+
+ // upload bun file
+ await matches(api.upload(file as any, { filename: "bunfile.png" }), "bunfile.png");
+
+ // upload via request
+ await matches(api.upload(new Request(url), { filename: "request.png" }), "request.png");
+
+ // upload via url
+ await matches(api.upload(url, { filename: "url.png" }), "url.png");
+
+ // upload via response
+ {
+ const response = await mockedBackend.request(url);
+ await matches(api.upload(response, { filename: "response.png" }), "response.png");
+ }
+
+ // upload via readable from bun
+ await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
+
+ // upload via readable from response
+ {
+ const response = (await mockedBackend.request(url)) as Response;
+ await matches(
+ await api.upload(response.body!, { filename: "readable.png" }),
+ "readable.png"
+ );
+ }
+ });
+});
diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts
index 0d025b9..c76d55f 100644
--- a/app/__test__/app/repro.spec.ts
+++ b/app/__test__/app/repro.spec.ts
@@ -70,4 +70,34 @@ describe("repros", async () => {
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
});
+
+ test.only("verify inversedBy", async () => {
+ const schema = proto.em(
+ {
+ products: proto.entity("products", {
+ title: proto.text()
+ }),
+ product_likes: proto.entity("product_likes", {
+ created_at: proto.date()
+ }),
+ users: proto.entity("users", {})
+ },
+ (fns, schema) => {
+ fns.relation(schema.product_likes).manyToOne(schema.products, { inversedBy: "likes" });
+ fns.relation(schema.product_likes).manyToOne(schema.users);
+ }
+ );
+ const app = createApp({ initialConfig: { data: schema.toJSON() } });
+ await app.build();
+
+ const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
+
+ expect(info.fields).toEqual(["id", "title"]);
+ expect(info.relations.listable).toEqual([
+ {
+ entity: "product_likes",
+ ref: "likes"
+ }
+ ]);
+ });
});
diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts
index b6318d6..4ca0ce1 100644
--- a/app/__test__/auth/authorize/authorize.spec.ts
+++ b/app/__test__/auth/authorize/authorize.spec.ts
@@ -16,10 +16,8 @@ describe("authorize", () => {
role: "admin"
};
- guard.setUserContext(user);
-
- expect(guard.granted("read")).toBe(true);
- expect(guard.granted("write")).toBe(true);
+ expect(guard.granted("read", user)).toBe(true);
+ expect(guard.granted("write", user)).toBe(true);
expect(() => guard.granted("something")).toThrow();
});
@@ -46,10 +44,8 @@ describe("authorize", () => {
role: "admin"
};
- guard.setUserContext(user);
-
- expect(guard.granted("read")).toBe(true);
- expect(guard.granted("write")).toBe(true);
+ expect(guard.granted("read", user)).toBe(true);
+ expect(guard.granted("write", user)).toBe(true);
});
test("guard implicit allow", async () => {
@@ -66,12 +62,12 @@ describe("authorize", () => {
}
});
- guard.setUserContext({
+ const user = {
role: "admin"
- });
+ };
- expect(guard.granted("read")).toBe(true);
- expect(guard.granted("write")).toBe(true);
+ expect(guard.granted("read", user)).toBe(true);
+ expect(guard.granted("write", user)).toBe(true);
});
test("guard with guest role implicit allow", async () => {
diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts
index b484be8..3de8a48 100644
--- a/app/__test__/core/utils.spec.ts
+++ b/app/__test__/core/utils.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { Perf } from "../../src/core/utils";
+import { Perf, isBlob, ucFirst } from "../../src/core/utils";
import * as utils from "../../src/core/utils";
async function wait(ms: number) {
@@ -75,6 +75,57 @@ describe("Core Utils", async () => {
const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
});
+
+ describe("guards", () => {
+ const types = {
+ blob: new Blob(),
+ file: new File([""], "file.txt"),
+ stream: new ReadableStream(),
+ arrayBuffer: new ArrayBuffer(10),
+ arrayBufferView: new Uint8Array(new ArrayBuffer(10))
+ };
+
+ const fns = [
+ [utils.isReadableStream, "stream"],
+ [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
+ [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
+ [utils.isArrayBuffer, "arrayBuffer"],
+ [utils.isArrayBufferView, "arrayBufferView"]
+ ] as const;
+
+ const additional = [0, 0.0, "", null, undefined, {}, []];
+
+ for (const [fn, type, _to_test] of fns) {
+ test(`is${ucFirst(type)}`, () => {
+ const to_test = _to_test ?? (Object.keys(types) as string[]);
+ for (const key of to_test) {
+ const value = types[key as keyof typeof types];
+ const result = fn(value);
+ expect(result).toBe(key === type);
+ }
+
+ for (const value of additional) {
+ const result = fn(value);
+ expect(result).toBe(false);
+ }
+ });
+ }
+ });
+
+ test("getContentName", () => {
+ const name = "test.json";
+ const text = "attachment; filename=" + name;
+ const headers = new Headers({
+ "Content-Disposition": text
+ });
+ const request = new Request("http://example.com", {
+ headers
+ });
+
+ expect(utils.getContentName(text)).toBe(name);
+ expect(utils.getContentName(headers)).toBe(name);
+ expect(utils.getContentName(request)).toBe(name);
+ });
});
describe("perf", async () => {
@@ -134,8 +185,8 @@ describe("Core Utils", async () => {
[true, true, true],
[true, false, false],
[false, false, true],
- [1, NaN, false],
- [NaN, NaN, true],
+ [1, Number.NaN, false],
+ [Number.NaN, Number.NaN, true],
[null, null, true],
[null, undefined, false],
[undefined, undefined, true],
diff --git a/app/__test__/data/DataController.spec.ts b/app/__test__/data/DataController.spec.ts
index 42ded5f..cec2022 100644
--- a/app/__test__/data/DataController.spec.ts
+++ b/app/__test__/data/DataController.spec.ts
@@ -116,7 +116,7 @@ describe("[data] DataController", async () => {
//console.log("app.routes", app.routes);
// create users
for await (const _user of fixtures.users) {
- const res = await app.request("/users", {
+ const res = await app.request("/entity/users", {
method: "POST",
body: JSON.stringify(_user)
});
@@ -131,7 +131,7 @@ describe("[data] DataController", async () => {
// create posts
for await (const _post of fixtures.posts) {
- const res = await app.request("/posts", {
+ const res = await app.request("/entity/posts", {
method: "POST",
body: JSON.stringify(_post)
});
@@ -145,7 +145,7 @@ describe("[data] DataController", async () => {
});
test("/:entity (read many)", async () => {
- const res = await app.request("/users");
+ const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3);
@@ -156,7 +156,7 @@ describe("[data] DataController", async () => {
});
test("/:entity/query (func query)", async () => {
- const res = await app.request("/users/query", {
+ const res = await app.request("/entity/users/query", {
method: "POST",
headers: {
"Content-Type": "application/json"
@@ -175,7 +175,7 @@ describe("[data] DataController", async () => {
});
test("/:entity (read many, paginated)", async () => {
- const res = await app.request("/users?limit=1&offset=2");
+ const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3);
@@ -186,7 +186,7 @@ describe("[data] DataController", async () => {
});
test("/:entity/:id (read one)", async () => {
- const res = await app.request("/users/3");
+ const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResponse;
console.log("data", data);
@@ -197,7 +197,7 @@ describe("[data] DataController", async () => {
});
test("/:entity (update one)", async () => {
- const res = await app.request("/users/3", {
+ const res = await app.request("/entity/users/3", {
method: "PATCH",
body: JSON.stringify({ name: "new name" })
});
@@ -208,7 +208,7 @@ describe("[data] DataController", async () => {
});
test("/:entity/:id/:reference (read references)", async () => {
- const res = await app.request("/users/1/posts");
+ const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResponse;
console.log("data", data);
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
});
test("/:entity/:id (delete one)", async () => {
- const res = await app.request("/posts/2", {
+ const res = await app.request("/entity/posts/2", {
method: "DELETE"
});
const { data } = (await res.json()) as RepositoryResponse;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify
- const res2 = await app.request("/posts");
+ const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1);
});
diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts
index f07cd34..8cafff2 100644
--- a/app/__test__/helper.ts
+++ b/app/__test__/helper.ts
@@ -68,3 +68,6 @@ export function schemaToEm(s: ReturnType, conn?: Connection): En
const connection = conn ? conn : getDummyConnection().dummyConnection;
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
}
+
+export const assetsPath = `${import.meta.dir}/_assets`;
+export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts
index c103848..6f2466c 100644
--- a/app/__test__/integration/auth.integration.test.ts
+++ b/app/__test__/integration/auth.integration.test.ts
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
+import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "../helper";
@@ -98,7 +99,7 @@ const fns = (app: App, mode?: Mode) =
}
return {
- Authorization: `Bearer ${token}`,
+ Authorization: token ? `Bearer ${token}` : "",
"Content-Type": "application/json",
...additional
};
@@ -210,4 +211,36 @@ describe("integration auth", () => {
expect(res.status).toBe(403);
});
});
+
+ it("context is exclusive", async () => {
+ const app = createAuthApp();
+ await app.build();
+ const $fns = fns(app);
+
+ app.server.get("/get", auth(), async (c) => {
+ return c.json({
+ user: c.get("auth").user ?? null
+ });
+ });
+ app.server.get("/wait", auth(), async (c) => {
+ await new Promise((r) => setTimeout(r, 20));
+ return c.json({ ok: true });
+ });
+
+ const { data } = await $fns.login(configs.users.normal);
+ const me = await $fns.me(data.token);
+ expect(me.user.email).toBe(configs.users.normal.email);
+
+ app.server.request("/wait", {
+ headers: { Authorization: `Bearer ${data.token}` }
+ });
+
+ {
+ await new Promise((r) => setTimeout(r, 10));
+ const res = await app.server.request("/get");
+ const data = await res.json();
+ expect(data.user).toBe(null);
+ expect(await $fns.me()).toEqual({ user: null as any });
+ }
+ });
});
diff --git a/app/__test__/integration/config.integration.test.ts b/app/__test__/integration/config.integration.test.ts
new file mode 100644
index 0000000..6eef035
--- /dev/null
+++ b/app/__test__/integration/config.integration.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "bun:test";
+import { createApp } from "../../src";
+import { Api } from "../../src/Api";
+
+describe("integration config", () => {
+ it("should create an entity", async () => {
+ const app = createApp();
+ await app.build();
+ const api = new Api({
+ host: "http://localhost",
+ fetcher: app.server.request as typeof fetch
+ });
+
+ // create entity
+ await api.system.addConfig("data", "entities.posts", {
+ name: "posts",
+ config: { sort_field: "id", sort_dir: "asc" },
+ fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } },
+ type: "regular"
+ });
+
+ expect(app.em.entities.map((e) => e.name)).toContain("posts");
+ });
+});
diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts
index 4816620..49b0900 100644
--- a/app/__test__/media/MediaController.spec.ts
+++ b/app/__test__/media/MediaController.spec.ts
@@ -1,56 +1,96 @@
-import { describe, test } from "bun:test";
-import { Hono } from "hono";
-import { Guard } from "../../src/auth";
-import { EventManager } from "../../src/core/events";
-import { EntityManager } from "../../src/data";
-import { AppMedia } from "../../src/media/AppMedia";
-import { MediaController } from "../../src/media/api/MediaController";
-import { getDummyConnection } from "../helper";
+///
-const { dummyConnection, afterAllCleanup } = getDummyConnection();
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { createApp, registries } from "../../src";
+import { StorageLocalAdapter } from "../../src/adapter/node";
+import { mergeObject, randomString } from "../../src/core/utils";
+import type { TAppMediaConfig } from "../../src/media/media-schema";
+import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
-/**
- * R2
- * value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
- * Node writefile
- * data: string | NodeJS.ArrayBufferView | Iterable | AsyncIterable | Stream,
- */
-const ALL_TESTS = !!process.env.ALL_TESTS;
-describe.skipIf(ALL_TESTS)("MediaController", () => {
- test("..", async () => {
- const ctx: any = {
- em: new EntityManager([], dummyConnection, []),
- guard: new Guard(),
- emgr: new EventManager(),
- server: new Hono()
- };
+beforeAll(() => {
+ registries.media.register("local", StorageLocalAdapter);
+});
- const media = new AppMedia(
- // @ts-ignore
- {
- enabled: true,
- adapter: {
- type: "s3",
- config: {
- access_key: process.env.R2_ACCESS_KEY as string,
- secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string,
- url: process.env.R2_URL as string
+const path = `${assetsPath}/image.png`;
+
+async function makeApp(mediaOverride: Partial = {}) {
+ const app = createApp({
+ initialConfig: {
+ media: mergeObject(
+ {
+ enabled: true,
+ adapter: {
+ type: "local",
+ config: {
+ path: assetsTmpPath
+ }
}
- }
- },
- ctx
- );
- await media.build();
- const app = new MediaController(media).getController();
+ },
+ mediaOverride
+ )
+ }
+ });
- const file = Bun.file(`${import.meta.dir}/adapters/icon.png`);
- console.log("file", file);
- const form = new FormData();
- form.append("file", file);
+ await app.build();
+ return app;
+}
- await app.request("/upload/test.png", {
+function makeName(ext: string) {
+ return randomString(10) + "." + ext;
+}
+
+beforeAll(disableConsoleLog);
+afterAll(enableConsoleLog);
+
+describe("MediaController", () => {
+ test("accepts direct", async () => {
+ const app = await makeApp();
+
+ const file = Bun.file(path);
+ const name = makeName("png");
+ const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file
});
+ const result = (await res.json()) as any;
+ expect(result.name).toBe(name);
+
+ const destFile = Bun.file(assetsTmpPath + "/" + name);
+ expect(destFile.exists()).resolves.toBe(true);
+ await destFile.delete();
+ });
+
+ test("accepts form data", async () => {
+ const app = await makeApp();
+
+ const file = Bun.file(path);
+ const name = makeName("png");
+ const form = new FormData();
+ form.append("file", file);
+
+ const res = await app.server.request("/api/media/upload/" + name, {
+ method: "POST",
+ body: form
+ });
+ const result = (await res.json()) as any;
+ expect(result.name).toBe(name);
+
+ const destFile = Bun.file(assetsTmpPath + "/" + name);
+ expect(destFile.exists()).resolves.toBe(true);
+ await destFile.delete();
+ });
+
+ test("limits body", async () => {
+ const app = await makeApp({ storage: { body_max_size: 1 } });
+
+ const file = await Bun.file(path);
+ const name = makeName("png");
+ const res = await app.server.request("/api/media/upload/" + name, {
+ method: "POST",
+ body: file
+ });
+
+ expect(res.status).toBe(413);
+ expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
});
});
diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts
index a7c6d79..2240aeb 100644
--- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts
+++ b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts
@@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => {
test("puts an object", async () => {
objects = (await adapter.listObjects()).length;
- expect(await adapter.putObject(filename, file)).toBeString();
+ expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
});
test("lists objects", async () => {
diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts
index e22afff..64e3cde 100644
--- a/app/__test__/modules/ModuleManager.spec.ts
+++ b/app/__test__/modules/ModuleManager.spec.ts
@@ -1,6 +1,7 @@
-import { describe, expect, test } from "bun:test";
-import { stripMark } from "../../src/core/utils";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
import { entity, text } from "../../src/data";
+import { Module } from "../../src/modules/Module";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "../helper";
@@ -177,7 +178,7 @@ describe("ModuleManager", async () => {
await mm.build();
const configs = stripMark(mm.configs());
- expect(mm.configs().server.admin.color_scheme).toBe("light");
+ expect(mm.configs().server.admin.color_scheme).toBeUndefined();
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
await mm.save();
@@ -252,4 +253,131 @@ describe("ModuleManager", async () => {
});
// @todo: add tests for migrations (check "backup" and new version)
+
+ describe("revert", async () => {
+ const failingModuleSchema = Type.Object({
+ value: Type.Optional(Type.Number())
+ });
+ class FailingModule extends Module {
+ getSchema() {
+ return failingModuleSchema;
+ }
+
+ override async build() {
+ //console.log("building FailingModule", this.config);
+ if (this.config.value < 0) {
+ throw new Error("value must be positive");
+ }
+ this.setBuilt();
+ }
+ }
+ class TestModuleManager extends ModuleManager {
+ constructor(...args: ConstructorParameters) {
+ super(...args);
+ const [, options] = args;
+ // @ts-ignore
+ const initial = options?.initial?.failing ?? {};
+ this.modules["failing"] = new FailingModule(initial, this.ctx());
+ this.modules["failing"].setListener(async (c) => {
+ // @ts-ignore
+ await this.onModuleConfigUpdated("failing", c);
+ });
+ }
+ }
+
+ beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
+ afterEach(enableConsoleLog);
+
+ test("it builds", async () => {
+ const { dummyConnection } = getDummyConnection();
+ const mm = new TestModuleManager(dummyConnection);
+ expect(mm).toBeDefined();
+ await mm.build();
+ expect(mm.toJSON()).toBeDefined();
+ });
+
+ test("it accepts config", async () => {
+ const { dummyConnection } = getDummyConnection();
+ const mm = new TestModuleManager(dummyConnection, {
+ initial: {
+ // @ts-ignore
+ failing: { value: 2 }
+ }
+ });
+ await mm.build();
+ expect(mm.configs()["failing"].value).toBe(2);
+ });
+
+ test("it crashes on invalid", async () => {
+ const { dummyConnection } = getDummyConnection();
+ const mm = new TestModuleManager(dummyConnection, {
+ initial: {
+ // @ts-ignore
+ failing: { value: -1 }
+ }
+ });
+ expect(mm.build()).rejects.toThrow(/value must be positive/);
+ expect(mm.configs()["failing"].value).toBe(-1);
+ });
+
+ test("it correctly accepts valid", async () => {
+ const mockOnUpdated = mock(() => null);
+ const { dummyConnection } = getDummyConnection();
+ const mm = new TestModuleManager(dummyConnection, {
+ onUpdated: async () => {
+ mockOnUpdated();
+ }
+ });
+ await mm.build();
+ // @ts-ignore
+ const f = mm.mutateConfigSafe("failing");
+
+ expect(f.set({ value: 2 })).resolves.toBeDefined();
+ expect(mockOnUpdated).toHaveBeenCalled();
+ });
+
+ test("it reverts on safe mutate", async () => {
+ const mockOnUpdated = mock(() => null);
+ const { dummyConnection } = getDummyConnection();
+ const mm = new TestModuleManager(dummyConnection, {
+ initial: {
+ // @ts-ignore
+ failing: { value: 1 }
+ },
+ onUpdated: async () => {
+ mockOnUpdated();
+ }
+ });
+ await mm.build();
+ expect(mm.configs()["failing"].value).toBe(1);
+
+ // now safe mutate
+ // @ts-ignore
+ expect(mm.mutateConfigSafe("failing").set({ value: -2 })).rejects.toThrow(
+ /value must be positive/
+ );
+ expect(mm.configs()["failing"].value).toBe(1);
+ expect(mockOnUpdated).toHaveBeenCalled();
+ });
+
+ test("it only accepts schema mutating methods", async () => {
+ const { dummyConnection } = getDummyConnection();
+ const mm = new TestModuleManager(dummyConnection);
+ await mm.build();
+
+ // @ts-ignore
+ const f = mm.mutateConfigSafe("failing");
+
+ // @ts-expect-error
+ expect(() => f.has("value")).toThrow();
+ // @ts-expect-error
+ expect(() => f.bypass()).toThrow();
+ // @ts-expect-error
+ expect(() => f.clone()).toThrow();
+ // @ts-expect-error
+ expect(() => f.get()).toThrow();
+ // @ts-expect-error
+ expect(() => f.default()).toThrow();
+ });
+ });
});
diff --git a/app/build.ts b/app/build.ts
index ebbf33a..ba14bc5 100644
--- a/app/build.ts
+++ b/app/build.ts
@@ -49,111 +49,117 @@ if (types && !watch) {
/**
* Building backend and general API
*/
-await tsup.build({
- minify,
- sourcemap,
- watch,
- entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
- outDir: "dist",
- external: ["bun:test", "@libsql/client"],
- metafile: true,
- platform: "browser",
- format: ["esm"],
- splitting: false,
- treeshake: true,
- loader: {
- ".svg": "dataurl"
- },
- onSuccess: async () => {
- delayTypes();
- }
-});
+async function buildApi() {
+ await tsup.build({
+ minify,
+ sourcemap,
+ watch,
+ entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
+ outDir: "dist",
+ external: ["bun:test", "@libsql/client", "bknd/client"],
+ metafile: true,
+ platform: "browser",
+ format: ["esm"],
+ splitting: false,
+ treeshake: true,
+ loader: {
+ ".svg": "dataurl"
+ },
+ onSuccess: async () => {
+ delayTypes();
+ }
+ });
+}
/**
* Building UI for direct imports
*/
-await tsup.build({
- minify,
- sourcemap,
- watch,
- entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
- outDir: "dist/ui",
- external: [
- "bun:test",
- "react",
- "react-dom",
- "react/jsx-runtime",
- "react/jsx-dev-runtime",
- "use-sync-external-store",
- /codemirror/,
- "@xyflow/react",
- "@mantine/core"
- ],
- metafile: true,
- platform: "browser",
- format: ["esm"],
- splitting: false,
- bundle: true,
- treeshake: true,
- loader: {
- ".svg": "dataurl"
- },
- esbuildOptions: (options) => {
- options.logLevel = "silent";
- },
- onSuccess: async () => {
- delayTypes();
- }
-});
+async function buildUi() {
+ await tsup.build({
+ minify,
+ sourcemap,
+ watch,
+ entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
+ outDir: "dist/ui",
+ external: [
+ "bun:test",
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "react/jsx-dev-runtime",
+ "use-sync-external-store",
+ /codemirror/,
+ "@xyflow/react",
+ "@mantine/core"
+ ],
+ metafile: true,
+ platform: "browser",
+ format: ["esm"],
+ splitting: false,
+ bundle: true,
+ treeshake: true,
+ loader: {
+ ".svg": "dataurl"
+ },
+ esbuildOptions: (options) => {
+ options.logLevel = "silent";
+ },
+ onSuccess: async () => {
+ delayTypes();
+ }
+ });
+}
/**
* Building UI Elements
* - tailwind-merge is mocked, no exclude
* - ui/client is external, and after built replaced with "bknd/client"
*/
-await tsup.build({
- minify,
- sourcemap,
- watch,
- entry: ["src/ui/elements/index.ts"],
- outDir: "dist/ui/elements",
- external: [
- "ui/client",
- "react",
- "react-dom",
- "react/jsx-runtime",
- "react/jsx-dev-runtime",
- "use-sync-external-store"
- ],
- metafile: true,
- platform: "browser",
- format: ["esm"],
- splitting: false,
- bundle: true,
- treeshake: true,
- loader: {
- ".svg": "dataurl"
- },
- esbuildOptions: (options) => {
- options.alias = {
- // not important for elements, mock to reduce bundle
- "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts"
- };
- },
- onSuccess: async () => {
- // manually replace ui/client with bknd/client
- const path = "./dist/ui/elements/index.js";
- const bundle = await Bun.file(path).text();
- await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
+async function buildUiElements() {
+ await tsup.build({
+ minify,
+ sourcemap,
+ watch,
+ entry: ["src/ui/elements/index.ts"],
+ outDir: "dist/ui/elements",
+ external: [
+ "ui/client",
+ "react",
+ "react-dom",
+ "react/jsx-runtime",
+ "react/jsx-dev-runtime",
+ "use-sync-external-store"
+ ],
+ metafile: true,
+ platform: "browser",
+ format: ["esm"],
+ splitting: false,
+ bundle: true,
+ treeshake: true,
+ loader: {
+ ".svg": "dataurl"
+ },
+ esbuildOptions: (options) => {
+ options.alias = {
+ // not important for elements, mock to reduce bundle
+ "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts"
+ };
+ },
+ onSuccess: async () => {
+ // manually replace ui/client with bknd/client
+ const path = "./dist/ui/elements/index.js";
+ const bundle = await Bun.file(path).text();
+ await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
- delayTypes();
- }
-});
+ delayTypes();
+ }
+ });
+}
/**
* Building adapters
*/
-function baseConfig(adapter: string): tsup.Options {
+function baseConfig(adapter: string, overrides: Partial = {}): tsup.Options {
return {
minify,
sourcemap,
@@ -162,47 +168,61 @@ function baseConfig(adapter: string): tsup.Options {
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
+ metafile: true,
+ splitting: false,
+ onSuccess: async () => {
+ delayTypes();
+ },
+ ...overrides,
define: {
- __isDev: "0"
+ __isDev: "0",
+ ...overrides.define
},
external: [
/^cloudflare*/,
/^@?(hono|libsql).*?/,
/^(bknd|react|next|node).*?/,
- /.*\.(html)$/
- ],
- metafile: true,
- splitting: false,
- onSuccess: async () => {
- delayTypes();
- }
+ /.*\.(html)$/,
+ ...(Array.isArray(overrides.external) ? overrides.external : [])
+ ]
};
}
-// base adapter handles
-await tsup.build({
- ...baseConfig(""),
- entry: ["src/adapter/index.ts"],
- outDir: "dist/adapter"
-});
+async function buildAdapters() {
+ // base adapter handles
+ await tsup.build({
+ ...baseConfig(""),
+ entry: ["src/adapter/index.ts"],
+ outDir: "dist/adapter"
+ });
-// specific adatpers
-await tsup.build(baseConfig("remix"));
-await tsup.build(baseConfig("bun"));
-await tsup.build(baseConfig("astro"));
-await tsup.build(baseConfig("cloudflare"));
+ // specific adatpers
+ await tsup.build(baseConfig("remix"));
+ await tsup.build(baseConfig("bun"));
+ await tsup.build(baseConfig("astro"));
+ await tsup.build(
+ baseConfig("cloudflare", {
+ external: [/^kysely/]
+ })
+ );
-await tsup.build({
- ...baseConfig("vite"),
- platform: "node"
-});
+ await tsup.build({
+ ...baseConfig("vite"),
+ platform: "node"
+ });
-await tsup.build({
- ...baseConfig("nextjs"),
- platform: "node"
-});
+ await tsup.build({
+ ...baseConfig("nextjs"),
+ platform: "node"
+ });
-await tsup.build({
- ...baseConfig("node"),
- platform: "node"
-});
+ await tsup.build({
+ ...baseConfig("node"),
+ platform: "node"
+ });
+}
+
+await buildApi();
+await buildUi();
+await buildUiElements();
+await buildAdapters();
diff --git a/app/package.json b/app/package.json
index 71ca15f..791ea86 100644
--- a/app/package.json
+++ b/app/package.json
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
- "version": "0.7.1",
+ "version": "0.8.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -49,17 +49,19 @@
"hono": "^4.6.12",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
+ "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
+ "picocolors": "^1.1.1",
"radix-ui": "^1.1.2",
- "json-schema-to-ts": "^3.1.1",
"swr": "^2.2.5"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
+ "@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0",
@@ -74,8 +76,10 @@
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"clsx": "^2.1.1",
+ "dotenv": "^16.4.7",
"esbuild-postcss": "^0.0.4",
"jotai": "^2.10.1",
+ "kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"postcss": "^8.4.47",
@@ -217,4 +221,4 @@
"bun",
"node"
]
-}
+}
\ No newline at end of file
diff --git a/app/src/Api.ts b/app/src/Api.ts
index f0946f0..2e35cee 100644
--- a/app/src/Api.ts
+++ b/app/src/Api.ts
@@ -22,6 +22,7 @@ export type ApiOptions = {
key?: string;
localStorage?: boolean;
fetcher?: typeof fetch;
+ verbose?: boolean;
verified?: boolean;
} & (
| {
@@ -196,7 +197,8 @@ export class Api {
host: this.baseUrl,
token: this.token,
headers: this.options.headers,
- token_transport: this.token_transport
+ token_transport: this.token_transport,
+ verbose: this.options.verbose
});
}
diff --git a/app/src/App.ts b/app/src/App.ts
index d32d57c..06917d0 100644
--- a/app/src/App.ts
+++ b/app/src/App.ts
@@ -1,9 +1,12 @@
import type { CreateUserPayload } from "auth/AppAuth";
+import { Api, type ApiOptions } from "bknd/client";
+import { $console } from "core";
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
type InitialModuleConfigs,
+ type ModuleBuildContext,
ModuleManager,
type ModuleManagerOptions,
type Modules
@@ -26,16 +29,22 @@ export class AppFirstBoot extends AppEvent {
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
+export type AppOptions = {
+ plugins?: AppPlugin[];
+ seed?: (ctx: ModuleBuildContext) => Promise;
+ manager?: Omit;
+};
export type CreateAppConfig = {
connection?:
| Connection
| {
+ // @deprecated
type: "libsql";
config: LibSqlCredentials;
- };
+ }
+ | LibSqlCredentials;
initialConfig?: InitialModuleConfigs;
- plugins?: AppPlugin[];
- options?: Omit;
+ options?: AppOptions;
};
export type AppConfig = InitialModuleConfigs;
@@ -45,30 +54,34 @@ export class App {
static readonly Events = AppEvents;
adminController?: AdminController;
private trigger_first_boot = false;
+ private plugins: AppPlugin[];
constructor(
private connection: Connection,
_initialConfig?: InitialModuleConfigs,
- private plugins: AppPlugin[] = [],
- moduleManagerOptions?: ModuleManagerOptions
+ private options?: AppOptions
) {
+ this.plugins = options?.plugins ?? [];
this.modules = new ModuleManager(connection, {
- ...moduleManagerOptions,
+ ...(options?.manager ?? {}),
initial: _initialConfig,
+ seed: options?.seed,
onUpdated: async (key, config) => {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
+ // this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
- console.warn("[APP] config updated, but event manager is disabled, skip.");
+ $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 });
+ $console.log("App config updated", key);
+ // @todo: potentially double syncing
+ await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
- console.log("[APP] first boot");
+ $console.log("App first boot");
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
@@ -85,16 +98,10 @@ export class App {
return this.modules.ctx().emgr;
}
- async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
+ async build(options?: { sync?: boolean }) {
+ if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build();
- if (options?.sync) {
- const syncResult = await this.module.data.em
- .schema()
- .sync({ force: true, drop: options.drop });
- //console.log("syncing", syncResult);
- }
-
const { guard, server } = this.modules.ctx();
// load system controller
@@ -108,12 +115,6 @@ export class App {
await this.emgr.emit(new AppBuiltEvent({ app: this }));
- 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;
@@ -122,7 +123,7 @@ export class App {
}
mutateConfig(module: Module) {
- return this.modules.get(module).schema();
+ return this.modules.mutateConfigSafe(module);
}
get server() {
@@ -178,6 +179,15 @@ export class App {
async createUser(p: CreateUserPayload) {
return this.module.auth.createUser(p);
}
+
+ getApi(options: Request | ApiOptions = {}) {
+ const fetcher = this.server.request as typeof fetch;
+ if (options instanceof Request) {
+ return new Api({ request: options, headers: options.headers, fetcher });
+ }
+
+ return new Api({ host: "http://localhost", ...options, fetcher });
+ }
}
export function createApp(config: CreateAppConfig = {}) {
@@ -187,18 +197,25 @@ export function createApp(config: CreateAppConfig = {}) {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
- connection = new LibsqlConnection(config.connection.config);
+ if ("type" in config.connection) {
+ $console.warn(
+ "Using deprecated connection type 'libsql', use the 'config' object directly."
+ );
+ connection = new LibsqlConnection(config.connection.config);
+ } else {
+ connection = new LibsqlConnection(config.connection);
+ }
} else {
connection = new LibsqlConnection({ url: ":memory:" });
- console.warn("[!] No connection provided, using in-memory database");
+ $console.warn("No connection provided, using in-memory database");
}
} catch (e) {
- console.error("Could not create connection", e);
+ $console.error("Could not create connection", e);
}
if (!connection) {
throw new Error("Invalid connection");
}
- return new App(connection, config.initialConfig, config.plugins, config.options);
+ return new App(connection, config.initialConfig, config.options);
}
diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts
index 05eec3c..851c54e 100644
--- a/app/src/adapter/bun/bun.adapter.ts
+++ b/app/src/adapter/bun/bun.adapter.ts
@@ -30,7 +30,6 @@ export function serve({
distPath,
connection,
initialConfig,
- plugins,
options,
port = config.server.default_port,
onBuilt,
@@ -44,7 +43,6 @@ export function serve({
const app = await createApp({
connection,
initialConfig,
- plugins,
options,
onBuilt,
buildConfig,
diff --git a/app/src/adapter/cloudflare/D1Connection.ts b/app/src/adapter/cloudflare/D1Connection.ts
new file mode 100644
index 0000000..810cca8
--- /dev/null
+++ b/app/src/adapter/cloudflare/D1Connection.ts
@@ -0,0 +1,63 @@
+///
+
+import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
+import type { QB } from "data/connection/Connection";
+import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
+import { D1Dialect } from "kysely-d1";
+
+export type D1ConnectionConfig = {
+ binding: D1Database;
+};
+
+class CustomD1Dialect extends D1Dialect {
+ override createIntrospector(db: Kysely): DatabaseIntrospector {
+ return new SqliteIntrospector(db, {
+ excludeTables: ["_cf_KV"]
+ });
+ }
+}
+
+export class D1Connection extends SqliteConnection {
+ constructor(private config: D1ConnectionConfig) {
+ const plugins = [new ParseJSONResultsPlugin()];
+
+ const kysely = new Kysely({
+ dialect: new CustomD1Dialect({ database: config.binding }),
+ plugins
+ });
+ super(kysely, {}, plugins);
+ }
+
+ override supportsBatching(): boolean {
+ return true;
+ }
+
+ override supportsIndices(): boolean {
+ return true;
+ }
+
+ protected override async batch(
+ queries: [...Queries]
+ ): Promise<{
+ [K in keyof Queries]: Awaited>;
+ }> {
+ const db = this.config.binding;
+
+ const res = await db.batch(
+ queries.map((q) => {
+ const { sql, parameters } = q.compile();
+ return db.prepare(sql).bind(...parameters);
+ })
+ );
+
+ // let it run through plugins
+ const kyselyPlugins = new KyselyPluginRunner(this.plugins);
+ const data: any = [];
+ for (const r of res) {
+ const rows = await kyselyPlugins.transformResultRows(r.results);
+ data.push(rows);
+ }
+
+ return data;
+ }
+}
diff --git a/app/src/media/storage/adapters/StorageR2Adapter.ts b/app/src/adapter/cloudflare/StorageR2Adapter.ts
similarity index 72%
rename from app/src/media/storage/adapters/StorageR2Adapter.ts
rename to app/src/adapter/cloudflare/StorageR2Adapter.ts
index 911fe37..aedeb10 100644
--- a/app/src/media/storage/adapters/StorageR2Adapter.ts
+++ b/app/src/adapter/cloudflare/StorageR2Adapter.ts
@@ -1,6 +1,47 @@
-import { isDebug } from "core";
-import type { FileBody, StorageAdapter } from "../Storage";
-import { guessMimeType } from "../mime-types";
+import { registries } from "bknd";
+import { isDebug } from "bknd/core";
+import { StringEnum, Type } from "bknd/utils";
+import type { FileBody, StorageAdapter } from "media/storage/Storage";
+import { guess } from "media/storage/mime-types-tiny";
+import { getBindings } from "./bindings";
+
+export function makeSchema(bindings: string[] = []) {
+ return Type.Object(
+ {
+ binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String())
+ },
+ { title: "R2", description: "Cloudflare R2 storage" }
+ );
+}
+
+export function registerMedia(env: Record) {
+ const r2_bindings = getBindings(env, "R2Bucket");
+
+ registries.media.register(
+ "r2",
+ class extends StorageR2Adapter {
+ constructor(private config: any) {
+ const binding = r2_bindings.find((b) => b.key === config.binding);
+ if (!binding) {
+ throw new Error(`No R2Bucket found with key ${config.binding}`);
+ }
+
+ super(binding?.value);
+ }
+
+ override getSchema() {
+ return makeSchema(r2_bindings.map((b) => b.key));
+ }
+
+ override toJSON() {
+ return {
+ ...super.toJSON(),
+ config: this.config
+ };
+ }
+ }
+ );
+}
/**
* Adapter for R2 storage
@@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter {
}
getSchema() {
- return undefined;
+ return makeSchema();
}
async putObject(key: string, body: FileBody) {
@@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter {
async getObject(key: string, headers: Headers): Promise {
let object: R2ObjectBody | null;
const responseHeaders = new Headers({
- "Accept-Ranges": "bytes"
+ "Accept-Ranges": "bytes",
+ "Content-Type": guess(key)
});
//console.log("getObject:headers", headersToObject(headers));
@@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter {
if (!metadata || Object.keys(metadata).length === 0) {
// guessing is especially required for dev environment (miniflare)
metadata = {
- contentType: guessMimeType(object.key)
+ contentType: guess(object.key)
};
}
- //console.log("writeHttpMetadata", object.httpMetadata, metadata);
for (const [key, value] of Object.entries(metadata)) {
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
@@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter {
}
return {
- type: String(head.httpMetadata?.contentType ?? "application/octet-stream"),
+ type: String(head.httpMetadata?.contentType ?? guess(key)),
size: head.size
};
}
diff --git a/app/src/adapter/cloudflare/bindings.ts b/app/src/adapter/cloudflare/bindings.ts
new file mode 100644
index 0000000..491d0a8
--- /dev/null
+++ b/app/src/adapter/cloudflare/bindings.ts
@@ -0,0 +1,32 @@
+export type BindingTypeMap = {
+ D1Database: D1Database;
+ KVNamespace: KVNamespace;
+ DurableObjectNamespace: DurableObjectNamespace;
+ R2Bucket: R2Bucket;
+};
+
+export type GetBindingType = keyof BindingTypeMap;
+export type BindingMap = { key: string; value: BindingTypeMap[T] };
+
+export function getBindings(env: any, type: T): BindingMap[] {
+ const bindings: BindingMap[] = [];
+ for (const key in env) {
+ try {
+ if (env[key] && (env[key] as any).constructor.name === type) {
+ bindings.push({
+ key,
+ value: env[key] as BindingTypeMap[T]
+ });
+ }
+ } catch (e) {}
+ }
+ return bindings;
+}
+
+export function getBinding(env: any, type: T): BindingMap {
+ const bindings = getBindings(env, type);
+ if (bindings.length === 0) {
+ throw new Error(`No ${type} found in bindings`);
+ }
+ return bindings[0] as BindingMap;
+}
diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
index 3a4044b..4486609 100644
--- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
+++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts
@@ -1,6 +1,11 @@
-import type { FrameworkBkndConfig } from "bknd/adapter";
+///
+
+import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
+import { D1Connection } from "./D1Connection";
+import { registerMedia } from "./StorageR2Adapter";
+import { getBinding } from "./bindings";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh";
@@ -10,6 +15,7 @@ export type CloudflareBkndConfig = FrameworkBkndConfig>
bindings?: (args: Context) => {
kv?: KVNamespace;
dobj?: DurableObjectNamespace;
+ db?: D1Database;
};
static?: "kv" | "assets";
key?: string;
@@ -26,7 +32,39 @@ export type Context = {
ctx: ExecutionContext;
};
-export function serve(config: CloudflareBkndConfig) {
+let media_registered: boolean = false;
+export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
+ if (!media_registered) {
+ registerMedia(context.env as any);
+ media_registered = true;
+ }
+
+ const appConfig = makeConfig(config, context);
+ const bindings = config.bindings?.(context);
+ if (!appConfig.connection) {
+ let db: D1Database | undefined;
+ if (bindings?.db) {
+ console.log("Using database from bindings");
+ db = bindings.db;
+ } else if (Object.keys(context.env ?? {}).length > 0) {
+ const binding = getBinding(context.env, "D1Database");
+ if (binding) {
+ console.log(`Using database from env "${binding.key}"`);
+ db = binding.value;
+ }
+ }
+
+ if (db) {
+ appConfig.connection = new D1Connection({ binding: db });
+ } else {
+ throw new Error("No database connection given");
+ }
+ }
+
+ return appConfig;
+}
+
+export function serve(config: CloudflareBkndConfig = {}) {
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
@@ -61,8 +99,6 @@ export function serve(config: CloudflareBkndConfig) {
}
}
- config.setAdminHtml = config.setAdminHtml && !!config.manifest;
-
const context = { request, env, ctx } as Context;
const mode = config.mode ?? "warm";
diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts
index c2dd1c5..a10dc53 100644
--- a/app/src/adapter/cloudflare/index.ts
+++ b/app/src/adapter/cloudflare/index.ts
@@ -1,4 +1,18 @@
+import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
+
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
+export { D1Connection, type D1ConnectionConfig };
+export {
+ getBinding,
+ getBindings,
+ type BindingTypeMap,
+ type GetBindingType,
+ type BindingMap
+} from "./bindings";
+
+export function d1(config: D1ConnectionConfig) {
+ return new D1Connection(config);
+}
diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts
index a367e5d..48f6926 100644
--- a/app/src/adapter/cloudflare/modes/cached.ts
+++ b/app/src/adapter/cloudflare/modes/cached.ts
@@ -1,6 +1,6 @@
import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
-import type { CloudflareBkndConfig, Context } from "../index";
+import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
const { kv } = config.bindings?.(env)!;
@@ -16,7 +16,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
const app = await createRuntimeApp(
{
- ...config,
+ ...makeCfConfig(config, { env, ctx, ...args }),
initialConfig,
onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts
index ef40987..7a2af67 100644
--- a/app/src/adapter/cloudflare/modes/fresh.ts
+++ b/app/src/adapter/cloudflare/modes/fresh.ts
@@ -1,11 +1,11 @@
import type { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
-import type { CloudflareBkndConfig, Context } from "../index";
+import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp(
{
- ...config,
+ ...makeCfConfig(config, ctx),
adminOptions: config.html ? { html: config.html } : undefined
},
ctx
diff --git a/app/src/adapter/remix/remix.adapter.ts b/app/src/adapter/remix/remix.adapter.ts
index 22470bb..38b81e8 100644
--- a/app/src/adapter/remix/remix.adapter.ts
+++ b/app/src/adapter/remix/remix.adapter.ts
@@ -1,6 +1,5 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
-import { Api } from "bknd/client";
export type RemixBkndConfig = FrameworkBkndConfig;
@@ -9,29 +8,30 @@ type RemixContext = {
};
let app: App;
+let building: boolean = false;
+
+export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
+ if (building) {
+ while (building) {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+ if (app) return app;
+ }
+
+ building = true;
+ if (!app) {
+ app = await createFrameworkApp(config, args);
+ await app.build();
+ }
+ building = false;
+ return app;
+}
+
export function serve(
config: RemixBkndConfig = {}
) {
return async (args: Args) => {
- if (!app) {
- app = await createFrameworkApp(config, args);
- }
+ app = await createFrameworkApp(config, args);
return app.fetch(args.request);
};
}
-
-export function withApi(
- handler: (args: Args, api: Api) => Promise
-) {
- return async (args: Args) => {
- if (!args.context.api) {
- args.context.api = new Api({
- host: new URL(args.request.url).origin,
- headers: args.request.headers
- });
- await args.context.api.verifyAuth();
- }
-
- return handler(args, args.context.api);
- };
-}
diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts
index 2ba24a5..3687395 100644
--- a/app/src/auth/api/AuthController.ts
+++ b/app/src/auth/api/AuthController.ts
@@ -104,10 +104,9 @@ export class AuthController extends Controller {
}
hono.get("/me", auth(), async (c) => {
- if (this.auth.authenticator.isUserLoggedIn()) {
- const claims = this.auth.authenticator.getUser()!;
+ const claims = c.get("auth")?.user;
+ if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
-
return c.json({ user });
}
@@ -133,6 +132,6 @@ export class AuthController extends Controller {
return c.json({ strategies, basepath });
});
- return hono;
+ return hono.all("*", (c) => c.notFound());
}
}
diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts
index 7853dcd..f869318 100644
--- a/app/src/auth/authenticate/Authenticator.ts
+++ b/app/src/auth/authenticate/Authenticator.ts
@@ -106,17 +106,15 @@ export type AuthUserResolver = (
identifier: string,
profile: ProfileExchange
) => Promise;
+type AuthClaims = SafeUser & {
+ iat: number;
+ iss?: string;
+ exp?: number;
+};
export class Authenticator = Record> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
- private _claims:
- | undefined
- | (SafeUser & {
- iat: number;
- iss?: string;
- exp?: number;
- });
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -148,21 +146,6 @@ export class Authenticator = Record<
return this.strategies;
}
- isUserLoggedIn(): boolean {
- return this._claims !== undefined;
- }
-
- getUser(): SafeUser | undefined {
- if (!this._claims) return;
-
- const { iat, exp, iss, ...user } = this._claims;
- return user;
- }
-
- resetUser() {
- this._claims = undefined;
- }
-
strategy<
StrategyName extends keyof Strategies,
Strat extends Strategy = Strategies[StrategyName]
@@ -206,7 +189,7 @@ export class Authenticator = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
}
- async verify(jwt: string): Promise {
+ async verify(jwt: string): Promise {
try {
const payload = await verify(
jwt,
@@ -221,14 +204,10 @@ export class Authenticator = Record<
}
}
- this._claims = payload as any;
- return true;
- } catch (e) {
- this.resetUser();
- //console.error(e);
- }
+ return payload as any;
+ } catch (e) {}
- return false;
+ return;
}
private get cookieOptions(): CookieOptions {
@@ -258,8 +237,8 @@ export class Authenticator = Record<
}
}
- async requestCookieRefresh(c: Context) {
- if (this.config.cookie.renew && this.isUserLoggedIn()) {
+ async requestCookieRefresh(c: Context) {
+ if (this.config.cookie.renew && c.get("auth")?.user) {
const token = await this.getAuthCookie(c);
if (token) {
await this.setAuthCookie(c, token);
@@ -276,13 +255,14 @@ export class Authenticator = Record<
await deleteCookie(c, "auth", this.cookieOptions);
}
- async logout(c: Context) {
+ async logout(c: Context) {
+ c.set("auth", undefined);
+
const cookie = await this.getAuthCookie(c);
if (cookie) {
await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info");
}
- this.resetUser();
}
// @todo: move this to a server helper
@@ -353,8 +333,7 @@ export class Authenticator = Record<
}
if (token) {
- await this.verify(token);
- return this.getUser();
+ return await this.verify(token);
}
return undefined;
diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts
index 15e3af2..8fbeadd 100644
--- a/app/src/auth/authorize/Guard.ts
+++ b/app/src/auth/authorize/Guard.ts
@@ -1,21 +1,23 @@
import { Exception, Permission } from "core";
import { objectTransform } from "core/utils";
+import type { Context } from "hono";
+import type { ServerEnv } from "modules/Module";
import { Role } from "./Role";
export type GuardUserContext = {
- role: string | null | undefined;
+ role?: string | null;
[key: string]: any;
};
export type GuardConfig = {
enabled?: boolean;
};
+export type GuardContext = Context | GuardUserContext;
const debug = false;
export class Guard {
permissions: Permission[];
- user?: GuardUserContext;
roles?: Role[];
config?: GuardConfig;
@@ -89,24 +91,19 @@ export class Guard {
return this;
}
- setUserContext(user: GuardUserContext | undefined) {
- this.user = user;
- return this;
- }
-
- getUserRole(): Role | undefined {
- if (this.user && typeof this.user.role === "string") {
- const role = this.roles?.find((role) => role.name === this.user?.role);
+ getUserRole(user?: GuardUserContext): Role | undefined {
+ if (user && typeof user.role === "string") {
+ const role = this.roles?.find((role) => role.name === user?.role);
if (role) {
- debug && console.log("guard: role found", [this.user.role]);
+ debug && console.log("guard: role found", [user.role]);
return role;
}
}
debug &&
console.log("guard: role not found", {
- user: this.user,
- role: this.user?.role
+ user: user,
+ role: user?.role
});
return this.getDefaultRole();
}
@@ -119,9 +116,9 @@ export class Guard {
return this.config?.enabled === true;
}
- hasPermission(permission: Permission): boolean;
- hasPermission(name: string): boolean;
- hasPermission(permissionOrName: Permission | string): boolean {
+ hasPermission(permission: Permission, user?: GuardUserContext): boolean;
+ hasPermission(name: string, user?: GuardUserContext): boolean;
+ hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) {
//console.log("guard not enabled, allowing");
return true;
@@ -133,7 +130,7 @@ export class Guard {
throw new Error(`Permission ${name} does not exist`);
}
- const role = this.getUserRole();
+ const role = this.getUserRole(user);
if (!role) {
debug && console.log("guard: role not found, denying");
@@ -156,12 +153,13 @@ export class Guard {
return !!rolePermission;
}
- granted(permission: Permission | string): boolean {
- return this.hasPermission(permission as any);
+ granted(permission: Permission | string, c?: GuardContext): boolean {
+ const user = c && "get" in c ? c.get("auth")?.user : c;
+ return this.hasPermission(permission as any, user);
}
- throwUnlessGranted(permission: Permission | string) {
- if (!this.granted(permission)) {
+ throwUnlessGranted(permission: Permission | string, c: GuardContext) {
+ if (!this.granted(permission, c)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403
diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts
index f36c94b..677478b 100644
--- a/app/src/auth/middlewares.ts
+++ b/app/src/auth/middlewares.ts
@@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) {
}
export function shouldSkip(c: Context, skip?: (string | RegExp)[]) {
- if (c.get("auth_skip")) return true;
+ const authCtx = c.get("auth");
+ if (!authCtx) {
+ throw new Error("auth ctx not found");
+ }
+
+ if (authCtx.skip) return true;
const req = c.req.raw;
if (!skip) return false;
@@ -18,7 +23,7 @@ export function shouldSkip(c: Context, skip?: (string | RegExp)[]) {
const path = getPath(req);
const result = skip.some((s) => patternMatch(path, s));
- c.set("auth_skip", result);
+ authCtx.skip = result;
return result;
}
@@ -26,29 +31,31 @@ export const auth = (options?: {
skip?: (string | RegExp)[];
}) =>
createMiddleware(async (c, next) => {
+ if (!c.get("auth")) {
+ c.set("auth", {
+ registered: false,
+ resolved: false,
+ skip: false,
+ user: undefined
+ });
+ }
+
const app = c.get("app");
- const guard = app?.modules.ctx().guard;
+ const authCtx = c.get("auth")!;
const authenticator = app?.module.auth.authenticator;
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
// make sure to only register once
- if (c.get("auth_registered")) {
+ if (authCtx.registered) {
skipped = true;
console.warn(`auth middleware already registered for ${getPath(c)}`);
} else {
- c.set("auth_registered", true);
+ authCtx.registered = true;
- 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);
- }
- }
+ if (!skipped && !authCtx.resolved && app?.module.auth.enabled) {
+ authCtx.user = await authenticator?.resolveAuthFromRequest(c);
+ authCtx.resolved = true;
}
}
@@ -60,9 +67,9 @@ export const auth = (options?: {
}
// release
- guard?.setUserContext(undefined);
- authenticator?.resetUser();
- c.set("auth_resolved", false);
+ authCtx.skip = false;
+ authCtx.resolved = false;
+ authCtx.user = undefined;
});
export const permission = (
@@ -75,23 +82,26 @@ export const permission = (
// @ts-ignore
createMiddleware(async (c, next) => {
const app = c.get("app");
- //console.log("skip?", c.get("auth_skip"));
+ const authCtx = c.get("auth");
+ if (!authCtx) {
+ throw new Error("auth ctx not found");
+ }
// in tests, app is not defined
- if (!c.get("auth_registered") || !app) {
+ if (!authCtx.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")) {
+ } else if (!authCtx.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))) {
+ if (permissions.every((p) => guard.granted(p, c))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
@@ -100,7 +110,7 @@ export const permission = (
return returned;
}
} else {
- permissions.some((p) => guard.throwUnlessGranted(p));
+ permissions.some((p) => guard.throwUnlessGranted(p, c));
}
}
diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts
new file mode 100644
index 0000000..6d74385
--- /dev/null
+++ b/app/src/cli/commands/create/create.ts
@@ -0,0 +1,294 @@
+import fs from "node:fs";
+import { downloadTemplate } from "@bluwy/giget-core";
+import * as $p from "@clack/prompts";
+import type { CliCommand } from "cli/types";
+import { typewriter, wait } from "cli/utils/cli";
+import { execAsync, getVersion } from "cli/utils/sys";
+import { Option } from "commander";
+import { colorizeConsole } from "core";
+import color from "picocolors";
+import { overridePackageJson, updateBkndPackages } from "./npm";
+import { type Template, templates } from "./templates";
+
+const config = {
+ types: {
+ runtime: "Runtime",
+ framework: "Framework"
+ },
+ runtime: {
+ node: "Node.js",
+ bun: "Bun",
+ cloudflare: "Cloudflare"
+ },
+ framework: {
+ nextjs: "Next.js",
+ remix: "Remix",
+ astro: "Astro"
+ }
+} as const;
+
+export const create: CliCommand = (program) => {
+ program
+ .command("create")
+ .addOption(new Option("-i, --integration ", "integration to use"))
+ .addOption(new Option("-t, --template ", "template to use"))
+ .addOption(new Option("-d --dir ", "directory to create in"))
+ .description("create a new project")
+ .action(action);
+};
+
+function errorOutro() {
+ $p.outro(color.red("Failed to create project."));
+ console.log(
+ color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") +
+ color.cyan("https://github.com/bknd-io/bknd/issues")
+ );
+ console.log("");
+ process.exit(1);
+}
+
+async function action(options: { template?: string; dir?: string; integration?: string }) {
+ console.log("");
+ colorizeConsole(console);
+
+ const downloadOpts = {
+ dir: options.dir || "./",
+ clean: false
+ };
+
+ const version = await getVersion();
+ $p.intro(
+ `👋 Welcome to the ${color.bold(color.cyan("bknd"))} create wizard ${color.bold(`v${version}`)}`
+ );
+
+ await $p.stream.message(
+ (async function* () {
+ yield* typewriter("Thanks for choosing to create a new project with bknd!", color.dim);
+ await wait();
+ })()
+ );
+
+ if (!options.dir) {
+ const dir = await $p.text({
+ message: "Where to create your project?",
+ placeholder: downloadOpts.dir,
+ initialValue: downloadOpts.dir
+ });
+ if ($p.isCancel(dir)) {
+ process.exit(1);
+ }
+
+ downloadOpts.dir = dir || "./";
+ }
+
+ if (fs.existsSync(downloadOpts.dir)) {
+ const clean = await $p.confirm({
+ message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
+ initialValue: false
+ });
+ if ($p.isCancel(clean)) {
+ process.exit(1);
+ }
+
+ downloadOpts.clean = clean;
+ }
+
+ let name = downloadOpts.dir.includes("/")
+ ? downloadOpts.dir.split("/").pop()
+ : downloadOpts.dir.replace(/[./]/g, "");
+
+ if (!name || name.length === 0) name = "bknd";
+
+ let template: Template | undefined;
+ if (options.template) {
+ template = templates.find((t) => t.key === options.template) as Template;
+ if (!template) {
+ $p.log.error(`Template ${color.cyan(options.template)} not found`);
+ process.exit(1);
+ }
+ } else {
+ let integration: string | undefined = options.integration;
+ if (!integration) {
+ await $p.stream.info(
+ (async function* () {
+ yield* typewriter("Ready? ", color.bold, 1.5);
+ await wait(2);
+ yield* typewriter("Let's find the perfect template for you.", color.dim);
+ await wait(2);
+ })()
+ );
+
+ const type = await $p.select({
+ message: "Pick an integration type",
+ options: Object.entries(config.types).map(([value, name]) => ({
+ value,
+ label: name,
+ hint: Object.values(config[value]).join(", ")
+ }))
+ });
+
+ if ($p.isCancel(type)) {
+ process.exit(1);
+ }
+
+ const _integration = await $p.select({
+ message: `Which ${color.cyan(config.types[type])} do you want to continue with?`,
+ options: Object.entries(config[type]).map(([value, name]) => ({
+ value,
+ label: name
+ })) as any
+ });
+ if ($p.isCancel(_integration)) {
+ process.exit(1);
+ }
+ integration = String(_integration);
+ }
+ if (!integration) {
+ $p.log.error("No integration selected");
+ process.exit(1);
+ }
+
+ //console.log("integration", { type, integration });
+
+ const choices = templates.filter((t) => t.integration === integration);
+ if (choices.length === 0) {
+ $p.log.error(`No templates found for "${color.cyan(String(integration))}"`);
+ process.exit(1);
+ } else if (choices.length > 1) {
+ const selected_template = await $p.select({
+ message: "Pick a template",
+ options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description }))
+ });
+
+ if ($p.isCancel(selected_template)) {
+ process.exit(1);
+ }
+
+ template = choices.find((t) => t.key === selected_template) as Template;
+ } else {
+ template = choices[0];
+ }
+ }
+ if (!template) {
+ $p.log.error("No template selected");
+ process.exit(1);
+ }
+
+ const ctx = { template, dir: downloadOpts.dir, name };
+
+ {
+ const ref = process.env.BKND_CLI_CREATE_REF ?? `v${version}`;
+ if (process.env.BKND_CLI_CREATE_REF) {
+ $p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(ref));
+ }
+
+ const prefix =
+ template.ref === true
+ ? `#${ref}`
+ : typeof template.ref === "string"
+ ? `#${template.ref}`
+ : "";
+ const url = `${template.path}${prefix}`;
+
+ //console.log("url", url);
+ const s = $p.spinner();
+ await s.start("Downloading template...");
+ try {
+ await downloadTemplate(url, {
+ dir: ctx.dir,
+ force: downloadOpts.clean ? "clean" : true
+ });
+ } catch (e) {
+ if (e instanceof Error) {
+ s.stop("Failed to download template: " + color.red(e.message), 1);
+ } else {
+ console.error(e);
+ s.stop("Failed to download template. Check logs above.", 1);
+ }
+
+ errorOutro();
+ }
+
+ s.stop("Template downloaded.");
+ await updateBkndPackages(ctx.dir);
+
+ if (template.preinstall) {
+ await template.preinstall(ctx);
+ }
+ }
+
+ // update package name
+ await overridePackageJson(
+ (pkg) => ({
+ ...pkg,
+ name: ctx.name
+ }),
+ { dir: ctx.dir }
+ );
+ $p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
+
+ {
+ const install = await $p.confirm({
+ message: "Install dependencies?"
+ });
+
+ if ($p.isCancel(install)) {
+ process.exit(1);
+ } else if (install) {
+ const install_cmd = template.scripts?.install || "npm install";
+
+ const s = $p.spinner();
+ await s.start("Installing dependencies...");
+ try {
+ await execAsync(`cd ${ctx.dir} && ${install_cmd}`, { silent: true });
+ } catch (e) {
+ if (e instanceof Error) {
+ s.stop("Failed to install: " + color.red(e.message), 1);
+ } else {
+ console.error(e);
+ s.stop("Failed to install. Check logs above.", 1);
+ }
+
+ errorOutro();
+ }
+
+ s.stop("Dependencies installed.");
+
+ if (template!.postinstall) {
+ await template.postinstall(ctx);
+ }
+ } else {
+ await $p.stream.warn(
+ (async function* () {
+ yield* typewriter(
+ color.dim("Remember to run ") +
+ color.cyan("npm install") +
+ color.dim(" after setup")
+ );
+ await wait();
+ })()
+ );
+ }
+ }
+
+ if (template.setup) {
+ await template.setup(ctx);
+ }
+
+ await $p.stream.success(
+ (async function* () {
+ yield* typewriter("That's it! ");
+ await wait(0.5);
+ yield "🎉";
+ await wait();
+ yield "\n\n";
+ yield* typewriter(
+ `Enter your project's directory using ${color.cyan("cd " + ctx.dir)}
+If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!`
+ );
+ await wait(2);
+ })()
+ );
+
+ $p.outro(color.green("Setup complete."));
+}
diff --git a/app/src/cli/commands/create/index.ts b/app/src/cli/commands/create/index.ts
new file mode 100644
index 0000000..c626200
--- /dev/null
+++ b/app/src/cli/commands/create/index.ts
@@ -0,0 +1 @@
+export * from "./create";
diff --git a/app/src/cli/commands/create/npm.ts b/app/src/cli/commands/create/npm.ts
new file mode 100644
index 0000000..49fd2c0
--- /dev/null
+++ b/app/src/cli/commands/create/npm.ts
@@ -0,0 +1,83 @@
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { getVersion as sysGetVersion } from "cli/utils/sys";
+
+export type TPackageJson = Partial<{
+ name: string;
+ main: string;
+ version: string;
+ scripts: Record;
+ dependencies: Record;
+ devDependencies: Record;
+ optionalDependencies: Record;
+ [key: string]: any;
+}>;
+
+export async function overrideJson(
+ file: string,
+ fn: (pkg: File) => Promise | File,
+ opts?: { dir?: string; indent?: number }
+) {
+ const pkgPath = path.resolve(opts?.dir ?? process.cwd(), file);
+ const pkg = await readFile(pkgPath, "utf-8");
+ const newPkg = await fn(JSON.parse(pkg));
+ await writeFile(pkgPath, JSON.stringify(newPkg, null, opts?.indent || 2));
+}
+
+export async function overridePackageJson(
+ fn: (pkg: TPackageJson) => Promise | TPackageJson,
+ opts?: { dir?: string }
+) {
+ return await overrideJson("package.json", fn, { dir: opts?.dir });
+}
+
+export async function getPackageInfo(pkg: string, version?: string): Promise {
+ const res = await fetch(`https://registry.npmjs.org/${pkg}${version ? `/${version}` : ""}`);
+ return await res.json();
+}
+
+export async function getVersion(pkg: string, version: string = "latest") {
+ const info = await getPackageInfo(pkg, version);
+ return info.version;
+}
+
+const _deps = ["dependencies", "devDependencies", "optionalDependencies"] as const;
+export async function replacePackageJsonVersions(
+ fn: (pkg: string, version: string) => Promise | string | undefined,
+ opts?: { include?: (keyof typeof _deps)[]; dir?: string }
+) {
+ const deps = (opts?.include ?? _deps) as string[];
+ await overridePackageJson(
+ async (json) => {
+ for (const dep of deps) {
+ if (dep in json) {
+ for (const [pkg, version] of Object.entries(json[dep])) {
+ const newVersion = await fn(pkg, version as string);
+ if (newVersion) {
+ json[dep][pkg] = newVersion;
+ }
+ }
+ }
+ }
+
+ return json;
+ },
+ { dir: opts?.dir }
+ );
+}
+
+export async function updateBkndPackages(dir?: string, map?: Record) {
+ const versions = {
+ bknd: "^" + (await sysGetVersion()),
+ ...(map ?? {})
+ };
+ await replacePackageJsonVersions(
+ async (pkg) => {
+ if (pkg in versions) {
+ return versions[pkg];
+ }
+ return;
+ },
+ { dir }
+ );
+}
diff --git a/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts
new file mode 100644
index 0000000..c2d714b
--- /dev/null
+++ b/app/src/cli/commands/create/templates/cloudflare.ts
@@ -0,0 +1,144 @@
+import * as $p from "@clack/prompts";
+import { overrideJson, overridePackageJson } from "cli/commands/create/npm";
+import { typewriter, wait } from "cli/utils/cli";
+import { uuid } from "core/utils";
+import c from "picocolors";
+import type { Template, TemplateSetupCtx } from ".";
+
+const WRANGLER_FILE = "wrangler.json";
+
+export const cloudflare = {
+ key: "cloudflare",
+ title: "Cloudflare Basic",
+ integration: "cloudflare",
+ description: "A basic bknd Cloudflare worker",
+ path: "gh:bknd-io/bknd/examples/cloudflare-worker",
+ ref: true,
+ setup: async (ctx) => {
+ // overwrite assets directory & name
+ await overrideJson(
+ WRANGLER_FILE,
+ (json) => ({
+ ...json,
+ name: ctx.name,
+ assets: {
+ directory: "node_modules/bknd/dist/static"
+ }
+ }),
+ { dir: ctx.dir }
+ );
+
+ const db = await $p.select({
+ message: "What database do you want to use?",
+ options: [
+ { label: "Cloudflare D1", value: "d1" },
+ { label: "LibSQL", value: "libsql" }
+ ]
+ });
+ if ($p.isCancel(db)) {
+ process.exit(1);
+ }
+
+ try {
+ switch (db) {
+ case "d1":
+ await createD1(ctx);
+ break;
+ case "libsql":
+ await createLibsql(ctx);
+ break;
+ default:
+ throw new Error("Invalid database");
+ }
+ } catch (e) {
+ const message = (e as any).message || "An error occurred";
+ $p.log.warn(
+ "Couldn't add database. You can add it manually later. Error: " + c.red(message)
+ );
+ }
+ }
+} as const satisfies Template;
+
+async function createD1(ctx: TemplateSetupCtx) {
+ const name = await $p.text({
+ message: "Enter database name",
+ initialValue: "data",
+ placeholder: "data",
+ validate: (v) => {
+ if (!v) {
+ return "Invalid name";
+ }
+ return;
+ }
+ });
+ if ($p.isCancel(name)) {
+ process.exit(1);
+ }
+
+ await overrideJson(
+ WRANGLER_FILE,
+ (json) => ({
+ ...json,
+ d1_databases: [
+ {
+ binding: "DB",
+ database_name: name,
+ database_id: uuid()
+ }
+ ]
+ }),
+ { dir: ctx.dir }
+ );
+
+ await $p.stream.info(
+ (async function* () {
+ yield* typewriter(`Database added to ${c.cyan("wrangler.json")}`);
+ await wait();
+ yield* typewriter(
+ `\nNote that if you deploy, you have to create a real database using ${c.cyan("npx wrangler d1 create ")} and update your wrangler configuration.`,
+ c.dim
+ );
+ })()
+ );
+}
+
+async function createLibsql(ctx: TemplateSetupCtx) {
+ await overrideJson(
+ WRANGLER_FILE,
+ (json) => ({
+ ...json,
+ vars: {
+ DB_URL: "http://127.0.0.1:8080"
+ }
+ }),
+ { dir: ctx.dir }
+ );
+
+ await overridePackageJson(
+ (pkg) => ({
+ ...pkg,
+ scripts: {
+ ...pkg.scripts,
+ db: "turso dev",
+ dev: "npm run db && wrangler dev"
+ }
+ }),
+ { dir: ctx.dir }
+ );
+
+ await $p.stream.info(
+ (async function* () {
+ yield* typewriter("Database set to LibSQL");
+ await wait();
+ yield* typewriter(
+ `\nYou can now run ${c.cyan("npm run db")} to start the database and ${c.cyan("npm run dev")} to start the worker.`,
+ c.dim
+ );
+ await wait();
+ yield* typewriter(
+ `\nAlso make sure you have Turso's CLI installed. Check their docs on how to install at ${c.cyan("https://docs.turso.tech/cli/introduction")}`,
+ c.dim
+ );
+ })()
+ );
+}
diff --git a/app/src/cli/commands/create/templates/index.ts b/app/src/cli/commands/create/templates/index.ts
new file mode 100644
index 0000000..469827f
--- /dev/null
+++ b/app/src/cli/commands/create/templates/index.ts
@@ -0,0 +1,64 @@
+import { cloudflare } from "./cloudflare";
+import { nextjs } from "./nextjs";
+import { remix } from "./remix";
+
+export type TemplateSetupCtx = {
+ template: Template;
+ dir: string;
+ name: string;
+};
+
+export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom";
+
+type TemplateScripts = "install" | "dev" | "build" | "start";
+export type Template = {
+ /**
+ * unique key for the template
+ */
+ key: string;
+ /**
+ * the integration this template is for
+ */
+ integration: Integration;
+ title: string;
+ description?: string;
+ path: string;
+ /**
+ * adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
+ */
+ ref?: true | string;
+ scripts?: Partial>;
+ preinstall?: (ctx: TemplateSetupCtx) => Promise;
+ postinstall?: (ctx: TemplateSetupCtx) => Promise;
+ setup?: (ctx: TemplateSetupCtx) => Promise;
+};
+
+export const templates: Template[] = [
+ cloudflare,
+ nextjs,
+ remix,
+ {
+ key: "node",
+ title: "Node.js Basic",
+ integration: "node",
+ description: "A basic bknd Node.js server",
+ path: "gh:bknd-io/bknd/examples/node",
+ ref: true
+ },
+ {
+ key: "bun",
+ title: "Bun Basic",
+ integration: "bun",
+ description: "A basic bknd Bun server",
+ path: "gh:bknd-io/bknd/examples/bun",
+ ref: true
+ },
+ {
+ key: "astro",
+ title: "Astro Basic",
+ integration: "astro",
+ description: "A basic bknd Astro starter",
+ path: "gh:bknd-io/bknd/examples/astro",
+ ref: true
+ }
+];
diff --git a/app/src/cli/commands/create/templates/nextjs.ts b/app/src/cli/commands/create/templates/nextjs.ts
new file mode 100644
index 0000000..bd36c2a
--- /dev/null
+++ b/app/src/cli/commands/create/templates/nextjs.ts
@@ -0,0 +1,29 @@
+import { overridePackageJson } from "cli/commands/create/npm";
+import type { Template } from ".";
+
+// @todo: add `concurrently`?
+export const nextjs = {
+ key: "nextjs",
+ title: "Next.js Basic",
+ integration: "nextjs",
+ description: "A basic bknd Next.js starter",
+ path: "gh:bknd-io/bknd/examples/nextjs",
+ scripts: {
+ install: "npm install --force"
+ },
+ ref: true,
+ preinstall: async (ctx) => {
+ // locally it's required to overwrite react, here it is not
+ await overridePackageJson(
+ (pkg) => ({
+ ...pkg,
+ dependencies: {
+ ...pkg.dependencies,
+ react: undefined,
+ "react-dom": undefined
+ }
+ }),
+ { dir: ctx.dir }
+ );
+ }
+} as const satisfies Template;
diff --git a/app/src/cli/commands/create/templates/remix.ts b/app/src/cli/commands/create/templates/remix.ts
new file mode 100644
index 0000000..42d494e
--- /dev/null
+++ b/app/src/cli/commands/create/templates/remix.ts
@@ -0,0 +1,25 @@
+import { overridePackageJson } from "cli/commands/create/npm";
+import type { Template } from ".";
+
+export const remix = {
+ key: "remix",
+ title: "Remix Basic",
+ integration: "remix",
+ description: "A basic bknd Remix starter",
+ path: "gh:bknd-io/bknd/examples/remix",
+ ref: true,
+ preinstall: async (ctx) => {
+ // locally it's required to overwrite react
+ await overridePackageJson(
+ (pkg) => ({
+ ...pkg,
+ dependencies: {
+ ...pkg.dependencies,
+ react: "^18.2.0",
+ "react-dom": "^18.2.0"
+ }
+ }),
+ { dir: ctx.dir }
+ );
+ }
+} as const satisfies Template;
diff --git a/app/src/cli/commands/debug.ts b/app/src/cli/commands/debug.ts
index 124d7d2..167e0ed 100644
--- a/app/src/cli/commands/debug.ts
+++ b/app/src/cli/commands/debug.ts
@@ -1,20 +1,44 @@
import path from "node:path";
import url from "node:url";
+import { createApp } from "App";
+import { getConnectionCredentialsFromEnv } from "cli/commands/run/platform";
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
+import { Argument } from "commander";
+import { showRoutes } from "hono/dev";
import type { CliCommand } from "../types";
export const debug: CliCommand = (program) => {
program
.command("debug")
- .description("debug path resolution")
- .action(() => {
- console.log("paths", {
- rootpath: getRootPath(),
- distPath: getDistPath(),
- relativeDistPath: getRelativeDistPath(),
- cwd: process.cwd(),
- dir: path.dirname(url.fileURLToPath(import.meta.url)),
- resolvedPkg: path.resolve(getRootPath(), "package.json")
- });
- });
+ .description("debug bknd")
+ .addArgument(new Argument("", "subject to debug").choices(Object.keys(subjects)))
+ .action(action);
};
+
+const subjects = {
+ paths: async () => {
+ console.log("[PATHS]", {
+ rootpath: getRootPath(),
+ distPath: getDistPath(),
+ relativeDistPath: getRelativeDistPath(),
+ cwd: process.cwd(),
+ dir: path.dirname(url.fileURLToPath(import.meta.url)),
+ resolvedPkg: path.resolve(getRootPath(), "package.json")
+ });
+ },
+ routes: async () => {
+ console.log("[APP ROUTES]");
+ const credentials = getConnectionCredentialsFromEnv();
+ const app = createApp({ connection: credentials });
+ await app.build();
+ showRoutes(app.server);
+ }
+};
+
+async function action(subject: string) {
+ if (subject in subjects) {
+ await subjects[subject]();
+ } else {
+ console.error("Invalid subject: ", subject);
+ }
+}
diff --git a/app/src/cli/commands/index.ts b/app/src/cli/commands/index.ts
index dfe7189..e27235f 100644
--- a/app/src/cli/commands/index.ts
+++ b/app/src/cli/commands/index.ts
@@ -3,3 +3,4 @@ export { schema } from "./schema";
export { run } from "./run";
export { debug } from "./debug";
export { user } from "./user";
+export { create } from "./create";
diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts
index 3038181..eda86e4 100644
--- a/app/src/cli/commands/run/platform.ts
+++ b/app/src/cli/commands/run/platform.ts
@@ -32,7 +32,7 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port;
- console.log(`(using ${server} serve)`);
+ console.log(`Using ${server} serve`);
switch (server) {
case "node": {
@@ -54,7 +54,7 @@ export async function startServer(server: Platform, app: any, options: { port: n
}
const url = `http://localhost:${port}`;
- console.log(`Server listening on ${url}`);
+ console.info("Server listening on", url);
await open(url);
}
@@ -76,3 +76,9 @@ export async function getConfigPath(filePath?: string) {
return;
}
+
+export function getConnectionCredentialsFromEnv() {
+ const dbUrl = process.env.DB_URL;
+ const dbToken = process.env.DB_TOKEN;
+ return dbUrl ? { url: dbUrl, authToken: dbToken } : undefined;
+}
diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts
index 0b6c843..c4e5442 100644
--- a/app/src/cli/commands/run/run.ts
+++ b/app/src/cli/commands/run/run.ts
@@ -3,16 +3,20 @@ import { App, type CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander";
-import { config } from "core";
+import { colorizeConsole, config } from "core";
+import dotenv from "dotenv";
import { registries } from "modules/registries";
+import c from "picocolors";
import {
PLATFORMS,
type Platform,
attachServeStatic,
getConfigPath,
+ getConnectionCredentialsFromEnv,
startServer
} from "./platform";
+dotenv.config();
const isBun = typeof Bun !== "undefined";
export const run: CliCommand = (program) => {
@@ -24,6 +28,13 @@ export const run: CliCommand = (program) => {
.default(config.server.default_port)
.argParser((v) => Number.parseInt(v))
)
+ .addOption(
+ new Option("-m, --memory", "use in-memory database").conflicts([
+ "config",
+ "db-url",
+ "db-token"
+ ])
+ )
.addOption(new Option("-c, --config ", "config file"))
.addOption(
new Option("--db-url ", "database url, can be any valid libsql url").conflicts(
@@ -94,23 +105,44 @@ export async function makeConfigApp(config: CliBkndConfig, platform?: Platform)
async function action(options: {
port: number;
+ memory?: boolean;
config?: string;
dbUrl?: string;
dbToken?: string;
server: Platform;
}) {
+ colorizeConsole(console);
const configFilePath = await getConfigPath(options.config);
- let app: App;
- if (options.dbUrl || !configFilePath) {
+ let app: App | undefined = undefined;
+ if (options.dbUrl) {
+ console.info("Using connection from", c.cyan("--db-url"));
const connection = options.dbUrl
- ? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } }
+ ? { url: options.dbUrl, authToken: options.dbToken }
: undefined;
app = await makeApp({ connection, server: { platform: options.server } });
- } else {
- console.log("Using config from:", configFilePath);
+ } else if (configFilePath) {
+ console.info("Using config from", c.cyan(configFilePath));
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server);
+ } else if (options.memory) {
+ console.info("Using", c.cyan("in-memory"), "connection");
+ app = await makeApp({ server: { platform: options.server } });
+ } else {
+ const credentials = getConnectionCredentialsFromEnv();
+ if (credentials) {
+ console.info("Using connection from env", c.cyan(credentials.url));
+ app = await makeConfigApp({ app: { connection: credentials } }, options.server);
+ }
+ }
+
+ if (!app) {
+ const connection = { url: "file:data.db" } as Config;
+ console.info("Using connection", c.cyan(connection.url));
+ app = await makeApp({
+ connection,
+ server: { platform: options.server }
+ });
}
await startServer(options.server, app, { port: options.port });
diff --git a/app/src/cli/index.ts b/app/src/cli/index.ts
index d7c7ef5..d749df8 100644
--- a/app/src/cli/index.ts
+++ b/app/src/cli/index.ts
@@ -1,15 +1,17 @@
#!/usr/bin/env node
import { Command } from "commander";
+import color from "picocolors";
import * as commands from "./commands";
import { getVersion } from "./utils/sys";
const program = new Command();
export async function main() {
+ const version = await getVersion();
program
.name("bknd")
- .description("bknd cli")
- .version(await getVersion());
+ .description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`)))
+ .version(version);
// register commands
for (const command of Object.values(commands)) {
diff --git a/app/src/cli/utils/cli.ts b/app/src/cli/utils/cli.ts
new file mode 100644
index 0000000..5b0d8e7
--- /dev/null
+++ b/app/src/cli/utils/cli.ts
@@ -0,0 +1,56 @@
+const _SPEEDUP = process.env.LOCAL;
+
+const DEFAULT_WAIT = _SPEEDUP ? 0 : 250;
+export async function wait(factor: number = 1, strict?: boolean) {
+ const ms = strict === true ? factor : DEFAULT_WAIT * factor;
+ return new Promise((r) => setTimeout(r, ms));
+}
+
+// from https://github.com/chalk/ansi-regex/blob/main/index.js
+export default function ansiRegex({ onlyFirst = false } = {}) {
+ // Valid string terminator sequences are BEL, ESC\, and 0x9c
+ const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
+ const pattern = [
+ `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
+ "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
+ ].join("|");
+
+ return new RegExp(pattern, onlyFirst ? undefined : "g");
+}
+
+const DEFAULT_WAIT_WRITER = _SPEEDUP ? 0 : 20;
+export async function* typewriter(
+ text: string,
+ transform?: (char: string) => string,
+ _delay?: number
+) {
+ const delay = DEFAULT_WAIT_WRITER * (_delay ?? 1);
+ const regex = ansiRegex();
+ const parts: string[] = [];
+ let match: RegExpExecArray | null;
+ let lastIndex = 0;
+
+ // Extract ANSI escape sequences as standalone units
+ // biome-ignore lint/suspicious/noAssignInExpressions:
+ while ((match = regex.exec(text)) !== null) {
+ if (lastIndex < match.index) {
+ parts.push(...text.slice(lastIndex, match.index).split(""));
+ }
+ parts.push(match[0]); // Add the ANSI escape sequence as a full chunk
+ lastIndex = regex.lastIndex;
+ }
+
+ // Add any remaining characters after the last ANSI sequence
+ if (lastIndex < text.length) {
+ parts.push(...text.slice(lastIndex).split(""));
+ }
+
+ // Yield characters or ANSI sequences in order
+ for (const chunk of parts) {
+ yield transform ? transform(chunk) : chunk;
+ // Delay only for normal characters, not ANSI codes
+ if (!regex.test(chunk)) {
+ await wait(delay, true);
+ }
+ }
+}
diff --git a/app/src/cli/utils/sys.ts b/app/src/cli/utils/sys.ts
index ab61fdd..4ea3531 100644
--- a/app/src/cli/utils/sys.ts
+++ b/app/src/cli/utils/sys.ts
@@ -1,3 +1,4 @@
+import { execSync, exec as nodeExec } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import url from "node:url";
@@ -16,9 +17,9 @@ export function getRelativeDistPath() {
return path.relative(process.cwd(), getDistPath());
}
-export async function getVersion() {
+export async function getVersion(_path: string = "") {
try {
- const resolved = path.resolve(getRootPath(), "package.json");
+ const resolved = path.resolve(getRootPath(), path.join(_path, "package.json"));
const pkg = await readFile(resolved, "utf-8");
if (pkg) {
return JSON.parse(pkg).version ?? "preview";
@@ -38,3 +39,35 @@ export async function fileExists(filePath: string) {
return false;
}
}
+
+export function exec(command: string, opts?: { silent?: boolean; env?: Record }) {
+ const stdio = opts?.silent ? "pipe" : "inherit";
+ const output = execSync(command, {
+ stdio: ["inherit", stdio, stdio],
+ env: { ...process.env, ...opts?.env }
+ });
+ if (!opts?.silent) {
+ return;
+ }
+ return output.toString();
+}
+
+export function execAsync(
+ command: string,
+ opts?: { silent?: boolean; env?: Record }
+) {
+ return new Promise((resolve, reject) => {
+ nodeExec(
+ command,
+ {
+ env: { ...process.env, ...opts?.env }
+ },
+ (err, stdout, stderr) => {
+ if (err) {
+ return reject(err);
+ }
+ resolve(stdout);
+ }
+ );
+ });
+}
diff --git a/app/src/core/console.ts b/app/src/core/console.ts
new file mode 100644
index 0000000..6bcb637
--- /dev/null
+++ b/app/src/core/console.ts
@@ -0,0 +1,105 @@
+import colors from "picocolors";
+
+function hasColors() {
+ try {
+ // biome-ignore lint/style/useSingleVarDeclarator:
+ const p = process || {},
+ argv = p.argv || [],
+ env = p.env || {};
+ return (
+ !(!!env.NO_COLOR || argv.includes("--no-color")) &&
+ // biome-ignore lint/complexity/useOptionalChain:
+ (!!env.FORCE_COLOR ||
+ argv.includes("--color") ||
+ p.platform === "win32" ||
+ ((p.stdout || {}).isTTY && env.TERM !== "dumb") ||
+ !!env.CI)
+ );
+ } catch (e) {
+ return false;
+ }
+}
+
+const originalConsoles = {
+ error: console.error,
+ warn: console.warn,
+ info: console.info,
+ log: console.log,
+ debug: console.debug
+} as typeof console;
+
+function __tty(type: any, args: any[]) {
+ const has = hasColors();
+ const styles = {
+ error: {
+ prefix: colors.red,
+ args: colors.red
+ },
+ warn: {
+ prefix: colors.yellow,
+ args: colors.yellow
+ },
+ info: {
+ prefix: colors.cyan
+ },
+ log: {
+ prefix: colors.gray
+ },
+ debug: {
+ prefix: colors.yellow
+ }
+ } as const;
+ const prefix = styles[type].prefix(
+ `[${type.toUpperCase()}]${has ? " ".repeat(5 - type.length) : ""}`
+ );
+ const _args = args.map((a) =>
+ "args" in styles[type] && has && typeof a === "string" ? styles[type].args(a) : a
+ );
+ return originalConsoles[type](prefix, ..._args);
+}
+
+export type TConsoleSeverity = keyof typeof originalConsoles;
+const severities = Object.keys(originalConsoles) as TConsoleSeverity[];
+
+let enabled = [...severities];
+
+export function disableConsole(severities: TConsoleSeverity[] = enabled) {
+ enabled = enabled.filter((s) => !severities.includes(s));
+}
+
+export function enableConsole() {
+ enabled = [...severities];
+}
+
+export const $console = new Proxy(
+ {},
+ {
+ get: (_, prop) => {
+ if (prop in originalConsoles && enabled.includes(prop as TConsoleSeverity)) {
+ return (...args: any[]) => __tty(prop, args);
+ }
+ return () => null;
+ }
+ }
+) as typeof console;
+
+export async function withDisabledConsole(
+ fn: () => Promise,
+ sev?: TConsoleSeverity[]
+): Promise {
+ disableConsole(sev);
+ try {
+ const result = await fn();
+ enableConsole();
+ return result;
+ } catch (e) {
+ enableConsole();
+ throw e;
+ }
+}
+
+export function colorizeConsole(con: typeof console) {
+ for (const [key] of Object.entries(originalConsoles)) {
+ con[key] = $console[key];
+ }
+}
diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts
index fc677ed..d3ac6ff 100644
--- a/app/src/core/events/EventListener.ts
+++ b/app/src/core/events/EventListener.ts
@@ -14,10 +14,17 @@ export class EventListener {
event: EventClass;
handler: ListenerHandler;
once: boolean = false;
+ id?: string;
- constructor(event: EventClass, handler: ListenerHandler, mode: ListenerMode = "async") {
+ constructor(
+ event: EventClass,
+ handler: ListenerHandler,
+ mode: ListenerMode = "async",
+ id?: string
+ ) {
this.event = event;
this.handler = handler;
this.mode = mode;
+ this.id = id;
}
}
diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts
index 73764ea..26549ba 100644
--- a/app/src/core/events/EventManager.ts
+++ b/app/src/core/events/EventManager.ts
@@ -6,6 +6,7 @@ export type RegisterListenerConfig =
| {
mode?: ListenerMode;
once?: boolean;
+ id?: string;
};
export interface EmitsEvents {
@@ -124,6 +125,15 @@ export class EventManager<
addListener(listener: EventListener) {
this.throwIfEventNotRegistered(listener.event);
+ if (listener.id) {
+ const existing = this.listeners.find((l) => l.id === listener.id);
+ if (existing) {
+ // @todo: add a verbose option?
+ //console.warn(`Listener with id "${listener.id}" already exists.`);
+ return this;
+ }
+ }
+
this.listeners.push(listener);
return this;
}
@@ -140,6 +150,9 @@ export class EventManager<
if (config.once) {
listener.once = true;
}
+ if (config.id) {
+ listener.id = `${event.slug}-${config.id}`;
+ }
this.addListener(listener as any);
}
diff --git a/app/src/core/index.ts b/app/src/core/index.ts
index 330e9fe..9f13b20 100644
--- a/app/src/core/index.ts
+++ b/app/src/core/index.ts
@@ -26,6 +26,8 @@ export {
} from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry";
+export * from "./console";
+
// compatibility
export type Middleware = MiddlewareHandler;
export interface ClassController {
diff --git a/app/src/core/utils/reqres.ts b/app/src/core/utils/reqres.ts
index 21e0e28..ea29a80 100644
--- a/app/src/core/utils/reqres.ts
+++ b/app/src/core/utils/reqres.ts
@@ -1,3 +1,7 @@
+import { randomString } from "core/utils/strings";
+import type { Context } from "hono";
+import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
+
export function headersToObject(headers: Headers): Record {
if (!headers) return {};
return { ...Object.fromEntries(headers.entries()) };
@@ -82,3 +86,259 @@ export function decodeSearch(str) {
return out;
}
+
+export function isReadableStream(value: unknown): value is ReadableStream {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as ReadableStream).getReader === "function"
+ );
+}
+
+export function isBlob(value: unknown): value is Blob {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as Blob).arrayBuffer === "function" &&
+ typeof (value as Blob).type === "string"
+ );
+}
+
+export function isFile(value: unknown): value is File {
+ return (
+ isBlob(value) &&
+ typeof (value as File).name === "string" &&
+ typeof (value as File).lastModified === "number"
+ );
+}
+
+export function isArrayBuffer(value: unknown): value is ArrayBuffer {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ Object.prototype.toString.call(value) === "[object ArrayBuffer]"
+ );
+}
+
+export function isArrayBufferView(value: unknown): value is ArrayBufferView {
+ return typeof value === "object" && value !== null && ArrayBuffer.isView(value);
+}
+
+export function getContentName(request: Request): string | undefined;
+export function getContentName(contentDisposition: string): string | undefined;
+export function getContentName(headers: Headers): string | undefined;
+export function getContentName(ctx: Headers | Request | string): string | undefined {
+ let c: string = "";
+
+ if (typeof ctx === "string") {
+ c = ctx;
+ } else if (ctx instanceof Headers) {
+ c = ctx.get("Content-Disposition") || "";
+ } else if (ctx instanceof Request) {
+ c = ctx.headers.get("Content-Disposition") || "";
+ }
+
+ const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/);
+ return match ? match[2] : undefined;
+}
+
+const FILE_SIGNATURES: Record = {
+ "89504E47": "image/png",
+ FFD8FF: "image/jpeg",
+ "47494638": "image/gif",
+ "49492A00": "image/tiff", // Little Endian TIFF
+ "4D4D002A": "image/tiff", // Big Endian TIFF
+ "52494646????57454250": "image/webp", // WEBP (RIFF....WEBP)
+ "504B0304": "application/zip",
+ "25504446": "application/pdf",
+ "00000020667479706D70": "video/mp4",
+ "000001BA": "video/mpeg",
+ "000001B3": "video/mpeg",
+ "1A45DFA3": "video/webm",
+ "4F676753": "audio/ogg",
+ "494433": "audio/mpeg", // MP3 with ID3 header
+ FFF1: "audio/aac",
+ FFF9: "audio/aac",
+ "52494646????41564920": "audio/wav",
+ "52494646????57415645": "audio/wave",
+ "52494646????415550": "audio/aiff"
+};
+
+async function detectMimeType(
+ input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null
+): Promise {
+ if (!input) return;
+
+ let buffer: Uint8Array;
+
+ if (isReadableStream(input)) {
+ const reader = input.getReader();
+ const { value } = await reader.read();
+ if (!value) return;
+ buffer = new Uint8Array(value);
+ } else if (isBlob(input) || isFile(input)) {
+ buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer());
+ } else if (isArrayBuffer(input)) {
+ buffer = new Uint8Array(input);
+ } else if (isArrayBufferView(input)) {
+ buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
+ } else if (typeof input === "string") {
+ buffer = new TextEncoder().encode(input);
+ } else {
+ return;
+ }
+
+ const hex = Array.from(buffer.slice(0, 12))
+ .map((b) => b.toString(16).padStart(2, "0").toUpperCase())
+ .join("");
+
+ for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) {
+ const regex = new RegExp("^" + signature.replace(/\?\?/g, ".."));
+ if (regex.test(hex)) return mime;
+ }
+
+ return;
+}
+
+export async function blobToFile(
+ blob: Blob | File | unknown,
+ overrides: FilePropertyBag & { name?: string } = {}
+): Promise {
+ if (isFile(blob)) return blob;
+ if (!isBlob(blob)) throw new Error("Not a Blob");
+
+ const type = isMimeType(overrides.type, ["application/octet-stream"])
+ ? overrides.type
+ : await detectMimeType(blob);
+ const ext = type ? extension(type) : "";
+ const name = overrides.name || [randomString(16), ext].filter(Boolean).join(".");
+
+ return new File([blob], name, {
+ type: type || guess(name),
+ lastModified: Date.now()
+ });
+}
+
+export async function getFileFromContext(c: Context): Promise {
+ const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
+
+ if (
+ contentType?.startsWith("multipart/form-data") ||
+ contentType?.startsWith("application/x-www-form-urlencoded")
+ ) {
+ try {
+ const f = await c.req.formData();
+ if ([...f.values()].length > 0) {
+ const v = [...f.values()][0];
+ return await blobToFile(v);
+ }
+ } catch (e) {
+ console.warn("Error parsing form data", e);
+ }
+ } else {
+ try {
+ const blob = await c.req.blob();
+ if (isFile(blob)) {
+ return blob;
+ } else if (isBlob(blob)) {
+ return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType });
+ }
+ } catch (e) {
+ console.warn("Error parsing blob", e);
+ }
+ }
+
+ throw new Error("No file found in request");
+}
+
+export async function getBodyFromContext(c: Context): Promise {
+ const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
+
+ if (
+ !contentType?.startsWith("multipart/form-data") &&
+ !contentType?.startsWith("application/x-www-form-urlencoded")
+ ) {
+ const body = c.req.raw.body;
+ if (body) {
+ return body;
+ }
+ }
+
+ return getFileFromContext(c);
+}
+
+// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
+// biome-ignore lint/suspicious/noConstEnum:
+export const enum HttpStatus {
+ // Informational responses (100–199)
+ CONTINUE = 100,
+ SWITCHING_PROTOCOLS = 101,
+ PROCESSING = 102,
+ EARLY_HINTS = 103,
+
+ // Successful responses (200–299)
+ OK = 200,
+ CREATED = 201,
+ ACCEPTED = 202,
+ NON_AUTHORITATIVE_INFORMATION = 203,
+ NO_CONTENT = 204,
+ RESET_CONTENT = 205,
+ PARTIAL_CONTENT = 206,
+ MULTI_STATUS = 207,
+ ALREADY_REPORTED = 208,
+ IM_USED = 226,
+
+ // Redirection messages (300–399)
+ MULTIPLE_CHOICES = 300,
+ MOVED_PERMANENTLY = 301,
+ FOUND = 302,
+ SEE_OTHER = 303,
+ NOT_MODIFIED = 304,
+ USE_PROXY = 305,
+ TEMPORARY_REDIRECT = 307,
+ PERMANENT_REDIRECT = 308,
+
+ // Client error responses (400–499)
+ BAD_REQUEST = 400,
+ UNAUTHORIZED = 401,
+ PAYMENT_REQUIRED = 402,
+ FORBIDDEN = 403,
+ NOT_FOUND = 404,
+ METHOD_NOT_ALLOWED = 405,
+ NOT_ACCEPTABLE = 406,
+ PROXY_AUTHENTICATION_REQUIRED = 407,
+ REQUEST_TIMEOUT = 408,
+ CONFLICT = 409,
+ GONE = 410,
+ LENGTH_REQUIRED = 411,
+ PRECONDITION_FAILED = 412,
+ PAYLOAD_TOO_LARGE = 413,
+ URI_TOO_LONG = 414,
+ UNSUPPORTED_MEDIA_TYPE = 415,
+ RANGE_NOT_SATISFIABLE = 416,
+ EXPECTATION_FAILED = 417,
+ IM_A_TEAPOT = 418,
+ MISDIRECTED_REQUEST = 421,
+ UNPROCESSABLE_ENTITY = 422,
+ LOCKED = 423,
+ FAILED_DEPENDENCY = 424,
+ TOO_EARLY = 425,
+ UPGRADE_REQUIRED = 426,
+ PRECONDITION_REQUIRED = 428,
+ TOO_MANY_REQUESTS = 429,
+ REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
+ UNAVAILABLE_FOR_LEGAL_REASONS = 451,
+
+ // Server error responses (500–599)
+ INTERNAL_SERVER_ERROR = 500,
+ NOT_IMPLEMENTED = 501,
+ BAD_GATEWAY = 502,
+ SERVICE_UNAVAILABLE = 503,
+ GATEWAY_TIMEOUT = 504,
+ HTTP_VERSION_NOT_SUPPORTED = 505,
+ VARIANT_ALSO_NEGOTIATES = 506,
+ INSUFFICIENT_STORAGE = 507,
+ LOOP_DETECTED = 508,
+ NOT_EXTENDED = 510,
+ NETWORK_AUTHENTICATION_REQUIRED = 511
+}
diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts
index 662b33c..0b9b69a 100644
--- a/app/src/core/utils/test.ts
+++ b/app/src/core/utils/test.ts
@@ -42,3 +42,21 @@ export function enableConsoleLog() {
console[severity as ConsoleSeverity] = fn;
});
}
+
+export function tryit(fn: () => void, fallback?: any) {
+ try {
+ return fn();
+ } catch (e) {
+ return fallback || e;
+ }
+}
+
+export function formatMemoryUsage() {
+ const usage = process.memoryUsage();
+ return {
+ rss: usage.rss / 1024 / 1024,
+ heapUsed: usage.heapUsed / 1024 / 1024,
+ external: usage.external / 1024 / 1024,
+ arrayBuffers: usage.arrayBuffers / 1024 / 1024
+ };
+}
diff --git a/app/src/data/api/DataApi.ts b/app/src/data/api/DataApi.ts
index e444092..abb710b 100644
--- a/app/src/data/api/DataApi.ts
+++ b/app/src/data/api/DataApi.ts
@@ -1,10 +1,10 @@
import type { DB } from "core";
-import type { EntityData, RepoQuery, RepoQueryIn, RepositoryResponse } from "data";
+import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
export type DataApiOptions = BaseModuleApiOptions & {
queryLengthLimit: number;
- defaultQuery: Partial;
+ defaultQuery: Partial;
};
export class DataApi extends ModuleApi {
@@ -23,7 +23,10 @@ export class DataApi extends ModuleApi {
id: PrimaryFieldType,
query: Omit = {}
) {
- return this.get, "meta" | "data">>([entity as any, id], query);
+ return this.get, "meta" | "data">>(
+ ["entity", entity as any, id],
+ query
+ );
}
readMany(
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi {
type T = Pick, "meta" | "data">;
const input = query ?? this.options.defaultQuery;
- const req = this.get([entity as any], input);
+ const req = this.get(["entity", entity as any], input);
if (req.request.url.length <= this.options.queryLengthLimit) {
return req;
}
- return this.post([entity as any, "query"], input);
+ return this.post(["entity", entity as any, "query"], input);
}
readManyByReference<
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi {
Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
return this.get, "meta" | "data">>(
- [entity as any, id, reference],
+ ["entity", entity as any, id, reference],
query ?? this.options.defaultQuery
);
}
@@ -57,7 +60,7 @@ export class DataApi extends ModuleApi {
entity: E,
input: Omit
) {
- return this.post>([entity as any], input);
+ return this.post>(["entity", entity as any], input);
}
updateOne(
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi {
id: PrimaryFieldType,
input: Partial>
) {
- return this.patch>([entity as any, id], input);
+ return this.patch>(["entity", entity as any, id], input);
}
deleteOne(
entity: E,
id: PrimaryFieldType
) {
- return this.delete>([entity as any, id]);
+ return this.delete>(["entity", entity as any, id]);
}
- count(entity: E, where: RepoQuery["where"] = {}) {
+ count(entity: E, where: RepoQueryIn["where"] = {}) {
return this.post>(
- [entity as any, "fn", "count"],
+ ["entity", entity as any, "fn", "count"],
where
);
}
diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts
index 131f3d6..1a44912 100644
--- a/app/src/data/api/DataController.ts
+++ b/app/src/data/api/DataController.ts
@@ -108,6 +108,103 @@ export class DataController extends Controller {
return c.json({ tables: tables.map((t) => t.name), changes });
});
+ /**
+ * Schema endpoints
+ */
+ hono
+ // read entity schema
+ .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: `${this.config.basepath}/schemas/${e.name}`
+ }
+ ])
+ );
+ return c.json({
+ $schema: "https://json-schema.org/draft/2020-12/schema",
+ $id,
+ properties: schemas
+ });
+ })
+ // read schema
+ .get(
+ "/schemas/:entity/:context?",
+ permission(DataPermissions.entityRead),
+ tb(
+ "param",
+ Type.Object({
+ entity: Type.String(),
+ context: Type.Optional(StringEnum(["create", "update"]))
+ })
+ ),
+ async (c) => {
+ //console.log("request", c.req.raw);
+ const { entity, context } = c.req.param();
+ if (!this.entityExists(entity)) {
+ console.warn("not found:", entity, definedEntities);
+ return c.notFound();
+ }
+ const _entity = this.em.entity(entity);
+ const schema = _entity.toSchema({ context } as any);
+ const url = new URL(c.req.url);
+ const base = `${url.origin}${this.config.basepath}`;
+ const $id = `${this.config.basepath}/schemas/${entity}`;
+ return c.json({
+ $schema: `${base}/schema.json`,
+ $id,
+ title: _entity.label,
+ $comment: _entity.config.description,
+ ...schema
+ });
+ }
+ );
+
+ // entity endpoints
+ hono.route("/entity", this.getEntityRoutes());
+
+ /**
+ * Info endpoints
+ */
+ hono.get("/info/:entity", async (c) => {
+ const { entity } = c.req.param();
+ if (!this.entityExists(entity)) {
+ return c.notFound();
+ }
+ const _entity = this.em.entity(entity);
+ const fields = _entity.fields.map((f) => f.name);
+ const $rels = (r: any) =>
+ r.map((r: any) => ({
+ entity: r.other(_entity).entity.name,
+ ref: r.other(_entity).reference
+ }));
+
+ return c.json({
+ name: _entity.name,
+ fields,
+ relations: {
+ all: $rels(this.em.relations.relationsOf(_entity)),
+ listable: $rels(this.em.relations.listableRelationsOf(_entity)),
+ source: $rels(this.em.relations.sourceRelationsOf(_entity)),
+ target: $rels(this.em.relations.targetRelationsOf(_entity))
+ }
+ });
+ });
+
+ return hono.all("*", (c) => c.notFound());
+ }
+
+ private getEntityRoutes() {
+ const { permission } = this.middlewares;
+ const hono = this.create();
+
+ const definedEntities = this.em.entities.map((e) => e.name);
+ const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
+ .Decode(Number.parseInt)
+ .Encode(String);
+
/**
* Function endpoints
*/
@@ -149,55 +246,6 @@ export class DataController extends Controller {
* Read endpoints
*/
hono
- // read entity schema
- .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: `${this.config.basepath}/schemas/${e.name}`
- }
- ])
- );
- return c.json({
- $schema: "https://json-schema.org/draft/2020-12/schema",
- $id,
- properties: schemas
- });
- })
- // read schema
- .get(
- "/schemas/:entity/:context?",
- permission(DataPermissions.entityRead),
- tb(
- "param",
- Type.Object({
- entity: Type.String(),
- context: Type.Optional(StringEnum(["create", "update"]))
- })
- ),
- async (c) => {
- //console.log("request", c.req.raw);
- 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({ context } as any);
- const url = new URL(c.req.url);
- const base = `${url.origin}${this.config.basepath}`;
- const $id = `${this.config.basepath}/schemas/${entity}`;
- return c.json({
- $schema: `${base}/schema.json`,
- $id,
- title: _entity.label,
- $comment: _entity.config.description,
- ...schema
- });
- }
- )
// read many
.get(
"/:entity",
@@ -208,7 +256,7 @@ export class DataController extends Controller {
//console.log("request", c.req.raw);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
- console.log("not found", entity, definedEntities);
+ console.warn("not found:", entity, definedEntities);
return c.notFound();
}
const options = c.req.valid("query") as RepoQuery;
@@ -327,12 +375,9 @@ export class DataController extends Controller {
// delete one
.delete(
"/:entity/:id",
-
permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => {
- this.guard.throwUnlessGranted(DataPermissions.entityDelete);
-
const { entity, id } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
@@ -350,14 +395,11 @@ export class DataController extends Controller {
tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where),
async (c) => {
- //console.log("request", c.req.raw);
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.valid("json") as RepoQuery["where"];
- //console.log("where", where);
-
const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result));
diff --git a/app/src/data/connection/LibsqlConnection.ts b/app/src/data/connection/LibsqlConnection.ts
index e60fa32..b8b9a9b 100644
--- a/app/src/data/connection/LibsqlConnection.ts
+++ b/app/src/data/connection/LibsqlConnection.ts
@@ -1,6 +1,6 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql";
-import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
+import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
import type { QB } from "./Connection";
@@ -28,7 +28,7 @@ export class LibsqlConnection extends SqliteConnection {
constructor(clientOrCredentials: Client | LibSqlCredentials) {
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
let client: Client;
- if ("url" in clientOrCredentials) {
+ if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
console.log("changing protocol to", protocol);
@@ -36,11 +36,8 @@ export class LibsqlConnection extends SqliteConnection {
url = `${protocol}://${rest}`;
}
- //console.log("using", url, { protocol });
-
client = createClient({ url, authToken });
} else {
- //console.log("-- client provided");
client = clientOrCredentials;
}
@@ -48,7 +45,6 @@ export class LibsqlConnection extends SqliteConnection {
// @ts-expect-error libsql has type issues
dialect: new CustomLibsqlDialect({ client }),
plugins
- //log: ["query"],
});
super(kysely, {}, plugins);
@@ -74,7 +70,6 @@ export class LibsqlConnection extends SqliteConnection {
}> {
const stms: InStatement[] = queries.map((q) => {
const compiled = q.compile();
- //console.log("compiled", compiled.sql, compiled.parameters);
return {
sql: compiled.sql,
args: compiled.parameters as any[]
@@ -91,7 +86,6 @@ export class LibsqlConnection extends SqliteConnection {
const rows = await kyselyPlugins.transformResultRows(r.rows);
data.push(rows);
}
- //console.log("data", data);
return data;
}
diff --git a/app/src/data/connection/SqliteIntrospector.ts b/app/src/data/connection/SqliteIntrospector.ts
index cf68816..516e8cf 100644
--- a/app/src/data/connection/SqliteIntrospector.ts
+++ b/app/src/data/connection/SqliteIntrospector.ts
@@ -5,7 +5,7 @@ import type {
ExpressionBuilder,
Kysely,
SchemaMetadata,
- TableMetadata,
+ TableMetadata
} from "kysely";
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
@@ -62,7 +62,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
seqno: number;
cid: number;
name: string;
- }>`pragma_index_info(${index})`.as("index_info"),
+ }>`pragma_index_info(${index})`.as("index_info")
)
.select(["seqno", "cid", "name"])
.orderBy("cid")
@@ -74,8 +74,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
isUnique: isUnique,
columns: columns.map((col) => ({
name: col.name,
- order: col.seqno,
- })),
+ order: col.seqno
+ }))
};
}
@@ -87,7 +87,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
}
async getTables(
- options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
+ options: DatabaseMetadataOptions = { withInternalKyselyTables: false }
): Promise {
let query = this.#db
.selectFrom("sqlite_master")
@@ -99,7 +99,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
if (!options.withInternalKyselyTables) {
query = query.where(
- this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]),
+ this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE])
);
}
if (this._excludeTables.length > 0) {
@@ -112,7 +112,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
async getMetadata(options?: DatabaseMetadataOptions): Promise {
return {
- tables: await this.getTables(options),
+ tables: await this.getTables(options)
};
}
@@ -142,7 +142,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
type: string;
notnull: 0 | 1;
dflt_value: any;
- }>`pragma_table_info(${table})`.as("table_info"),
+ }>`pragma_table_info(${table})`.as("table_info")
)
.select(["name", "type", "notnull", "dflt_value"])
.orderBy("cid")
@@ -157,8 +157,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.dflt_value != null,
- comment: undefined,
- })),
+ comment: undefined
+ }))
};
}
}
diff --git a/app/src/data/entities/Entity.ts b/app/src/data/entities/Entity.ts
index 3365190..69a8d4a 100644
--- a/app/src/data/entities/Entity.ts
+++ b/app/src/data/entities/Entity.ts
@@ -165,6 +165,13 @@ export class Entity<
return this.getField(name);
}
+ hasField(name: string): boolean;
+ hasField(field: Field): boolean;
+ hasField(nameOrField: string | Field): boolean {
+ const name = typeof nameOrField === "string" ? nameOrField : nameOrField.name;
+ return this.fields.findIndex((field) => field.name === name) !== -1;
+ }
+
getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual());
diff --git a/app/src/data/entities/Mutator.ts b/app/src/data/entities/Mutator.ts
index 15760bc..198cb11 100644
--- a/app/src/data/entities/Mutator.ts
+++ b/app/src/data/entities/Mutator.ts
@@ -107,18 +107,24 @@ export class Mutator<
protected async many(qb: MutatorQB): Promise {
const entity = this.entity;
const { sql, parameters } = qb.compile();
- //console.log("mutatoar:exec", sql, parameters);
- const result = await qb.execute();
- const data = this.em.hydrate(entity.name, result) as EntityData[];
+ try {
+ const result = await qb.execute();
- return {
- entity,
- sql,
- parameters: [...parameters],
- result: result,
- data
- };
+ const data = this.em.hydrate(entity.name, result) as EntityData[];
+
+ return {
+ entity,
+ sql,
+ parameters: [...parameters],
+ result: result,
+ data
+ };
+ } catch (e) {
+ // @todo: redact
+ console.log("[Error in query]", sql);
+ throw e;
+ }
}
protected async single(qb: MutatorQB): Promise> {
diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts
index 5234bc4..8ddb10f 100644
--- a/app/src/data/entities/query/Repository.ts
+++ b/app/src/data/entities/query/Repository.ts
@@ -1,4 +1,5 @@
import type { DB as DefaultDB, PrimaryFieldType } from "core";
+import { $console } from "core";
import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es";
@@ -73,7 +74,6 @@ export class Repository 0) {
- throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
+ throw new InvalidSearchParamsException(
+ `Invalid where field(s): ${invalid.join(", ")}`
+ ).context({ aliases, entity: entity.name });
}
validated.where = options.where;
@@ -160,7 +162,7 @@ export class Repository {
const entity = this.entity;
const compiled = qb.compile();
- //console.log("performQuery", compiled.sql, compiled.parameters);
+ //$console.log("performQuery", compiled.sql, compiled.parameters);
const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll().as(as);
@@ -179,7 +181,7 @@ export class Repository): Promise> {
const { qb, options } = this.buildQuery(_options);
- //console.log("findMany:options", options);
await this.emgr.emit(
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
@@ -386,7 +387,6 @@ export class Repository extends Field<
}
override async transformPersist(value: any): Promise {
- throw new Error("This function should not be called");
+ throw new Error("PrimaryField: This function should not be called");
}
override toJsonSchema() {
diff --git a/app/src/data/index.ts b/app/src/data/index.ts
index a5de079..e63707e 100644
--- a/app/src/data/index.ts
+++ b/app/src/data/index.ts
@@ -18,6 +18,8 @@ export { Connection } from "./connection/Connection";
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
export { SqliteConnection } from "./connection/SqliteConnection";
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
+export { SqliteIntrospector } from "./connection/SqliteIntrospector";
+export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
export { constructEntity, constructRelation } from "./schema/constructor";
diff --git a/app/src/data/relations/ManyToManyRelation.ts b/app/src/data/relations/ManyToManyRelation.ts
index b4e89f1..f96ab74 100644
--- a/app/src/data/relations/ManyToManyRelation.ts
+++ b/app/src/data/relations/ManyToManyRelation.ts
@@ -105,13 +105,13 @@ export class ManyToManyRelation extends EntityRelation {
- const conn = this.connectionEntity;
+ const { other, otherRef } = this.getQueryInfo(entity);
return {
where: {
- [`${conn.name}.${entity.name}_${entity.getPrimaryField().name}`]: id
+ [otherRef]: id
},
- join: [this.target.reference]
+ join: [other.reference]
};
}
@@ -160,47 +160,27 @@ export class ManyToManyRelation extends EntityRelation {
- const select: any[] = other.entity.getSelect(other.entity.name);
- // @todo: also add to find by references
- if (additionalFields.length > 0) {
- const conn = this.connectionEntity.name;
- select.push(
- jsonBuildObject(
- Object.fromEntries(
- additionalFields.map((f) => [f.name, eb.ref(`${conn}.${f.name}`)])
- )
- ).as(this.connectionTableMappedName)
- );
- }
-
- return jsonFrom(
- eb
- .selectFrom(other.entity.name)
- .select(select)
- .whereRef(entityRef, "=", otherRef)
- .innerJoin(...join)
- .limit(limit)
- ).as(other.reference);
- });*/
}
initialize(em: EntityManager) {
this.em = em;
- //this.connectionEntity.addField(new RelationField(this.source.entity));
- //this.connectionEntity.addField(new RelationField(this.target.entity));
- this.connectionEntity.addField(RelationField.create(this, this.source));
- this.connectionEntity.addField(RelationField.create(this, this.target));
+ const sourceField = RelationField.create(this, this.source);
+ const targetField = RelationField.create(this, this.target);
- // @todo: check this
- for (const field of this.additionalFields) {
- this.source.entity.addField(new VirtualField(this.connectionTableMappedName));
- this.target.entity.addField(new VirtualField(this.connectionTableMappedName));
+ if (em.hasEntity(this.connectionEntity)) {
+ // @todo: also check for correct signatures of field
+ if (!this.connectionEntity.hasField(sourceField)) {
+ this.connectionEntity.addField(sourceField);
+ }
+ if (!this.connectionEntity.hasField(targetField)) {
+ this.connectionEntity.addField(targetField);
+ }
+ } else {
+ this.connectionEntity.addField(sourceField);
+ this.connectionEntity.addField(targetField);
+ em.addEntity(this.connectionEntity);
}
-
- em.addEntity(this.connectionEntity);
}
override getName(): string {
diff --git a/app/src/data/relations/RelationField.ts b/app/src/data/relations/RelationField.ts
index f3d557f..0405ed5 100644
--- a/app/src/data/relations/RelationField.ts
+++ b/app/src/data/relations/RelationField.ts
@@ -88,7 +88,7 @@ export class RelationField extends Field {
}
override async transformPersist(value: any, em: EntityManager): Promise {
- throw new Error("This function should not be called");
+ throw new Error("RelationField: This function should not be called");
}
override toJsonSchema() {
diff --git a/app/src/data/relations/RelationMutator.ts b/app/src/data/relations/RelationMutator.ts
index 3aa85b0..2becd17 100644
--- a/app/src/data/relations/RelationMutator.ts
+++ b/app/src/data/relations/RelationMutator.ts
@@ -2,6 +2,7 @@ import type { PrimaryFieldType } from "core";
import type { Entity, EntityManager } from "../entities";
import {
type EntityRelation,
+ ManyToManyRelation,
type MutationOperation,
MutationOperations,
RelationField
@@ -26,11 +27,26 @@ export class RelationMutator {
*/
getRelationalKeys(): string[] {
const references: string[] = [];
+
+ // if persisting a manytomany connection table
+ // @todo: improve later
+ if (this.entity.type === "generated") {
+ const relation = this.em.relations.all.find(
+ (r) => r instanceof ManyToManyRelation && r.connectionEntity.name === this.entity.name
+ );
+ if (relation instanceof ManyToManyRelation) {
+ references.push(
+ ...this.entity.fields.filter((f) => f.type === "relation").map((f) => f.name)
+ );
+ }
+ }
+
this.em.relationsOf(this.entity.name).map((r) => {
const info = r.helper(this.entity.name).getMutationInfo();
references.push(info.reference);
info.local_field && references.push(info.local_field);
});
+
return references;
}
diff --git a/app/src/flows/AppFlows.ts b/app/src/flows/AppFlows.ts
index 4f75cbf..c31e051 100644
--- a/app/src/flows/AppFlows.ts
+++ b/app/src/flows/AppFlows.ts
@@ -63,6 +63,8 @@ export class AppFlows extends Module {
});
});
+ hono.all("*", (c) => c.notFound());
+
this.ctx.server.route(this.config.basepath, hono);
// register flows
diff --git a/app/src/index.ts b/app/src/index.ts
index 9593e7c..42ab9f8 100644
--- a/app/src/index.ts
+++ b/app/src/index.ts
@@ -1,4 +1,11 @@
-export { App, createApp, AppEvents, type AppConfig, type CreateAppConfig } from "./App";
+export {
+ App,
+ createApp,
+ AppEvents,
+ type AppConfig,
+ type CreateAppConfig,
+ type AppPlugin
+} from "./App";
export {
getDefaultConfig,
@@ -6,7 +13,8 @@ export {
type ModuleConfigs,
type ModuleSchemas,
type ModuleManagerOptions,
- type ModuleBuildContext
+ type ModuleBuildContext,
+ type InitialModuleConfigs
} from "./modules/ModuleManager";
export * as middlewares from "modules/middlewares";
diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts
index 564b008..b2040ce 100644
--- a/app/src/media/AppMedia.ts
+++ b/app/src/media/AppMedia.ts
@@ -40,7 +40,8 @@ export class AppMedia extends Module {
let adapter: StorageAdapter;
try {
const { type, config } = this.config.adapter;
- adapter = new (registry.get(type as any).cls)(config as any);
+ const cls = registry.get(type as any).cls;
+ adapter = new cls(config as any);
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
this.setBuilt();
@@ -53,8 +54,6 @@ export class AppMedia extends Module {
index(media).on(["path"], true).on(["reference"]);
})
);
-
- this.setBuilt();
} catch (e) {
console.error(e);
throw new Error(
@@ -124,11 +123,11 @@ export class AppMedia extends Module {
async (e) => {
const mutator = em.mutator(media);
mutator.__unstable_toggleSystemEntityCreation(false);
- await mutator.insertOne(this.uploadedEventDataToMediaPayload(e.params));
+ const payload = this.uploadedEventDataToMediaPayload(e.params);
+ await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true);
- console.log("App:storage:file uploaded", e);
},
- "sync"
+ { mode: "sync", id: "add-data-media" }
);
// when file is deleted, sync with media entity
@@ -144,7 +143,7 @@ export class AppMedia extends Module {
console.log("App:storage:file deleted", e);
},
- "sync"
+ { mode: "sync", id: "delete-data-media" }
);
}
diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts
index 722f94d..bf3277b 100644
--- a/app/src/media/api/MediaApi.ts
+++ b/app/src/media/api/MediaApi.ts
@@ -1,4 +1,10 @@
-import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi";
+import type { FileListObject } from "media";
+import {
+ type BaseModuleApiOptions,
+ ModuleApi,
+ type PrimaryFieldType,
+ type TInput
+} from "modules/ModuleApi";
import type { FileWithPath } from "ui/elements/media/file-selector";
export type MediaApiOptions = BaseModuleApiOptions & {};
@@ -10,12 +16,32 @@ export class MediaApi extends ModuleApi {
};
}
- getFiles() {
- return this.get(["files"]);
+ listFiles() {
+ return this.get(["files"]);
}
getFile(filename: string) {
- return this.get(["file", filename]);
+ return this.get>(["file", filename], undefined, {
+ headers: {
+ Accept: "*/*"
+ }
+ });
+ }
+
+ async getFileStream(filename: string): Promise> {
+ const { res } = await this.getFile(filename);
+ if (!res.ok || !res.body) {
+ throw new Error("Failed to fetch file");
+ }
+ return res.body;
+ }
+
+ async download(filename: string): Promise {
+ const { res } = await this.getFile(filename);
+ if (!res.ok || !res.body) {
+ throw new Error("Failed to fetch file");
+ }
+ return (await res.blob()) as File;
}
getFileUploadUrl(file: FileWithPath): string {
@@ -32,10 +58,93 @@ export class MediaApi extends ModuleApi {
});
}
- uploadFile(file: File) {
- const formData = new FormData();
- formData.append("file", file);
- return this.post(["upload"], formData);
+ protected uploadFile(
+ body: File | ReadableStream,
+ opts?: {
+ filename?: string;
+ path?: TInput;
+ _init?: Omit;
+ }
+ ) {
+ const headers = {
+ "Content-Type": "application/octet-stream",
+ ...(opts?._init?.headers || {})
+ };
+ let name: string = opts?.filename || "";
+ try {
+ if (typeof (body as File).type !== "undefined") {
+ headers["Content-Type"] = (body as File).type;
+ }
+ if (!opts?.filename) {
+ name = (body as File).name;
+ }
+ } catch (e) {}
+
+ if (name && name.length > 0 && name.includes("/")) {
+ name = name.split("/").pop() || "";
+ }
+
+ const init = {
+ ...(opts?._init || {}),
+ headers
+ };
+ if (opts?.path) {
+ return this.post(opts.path, body, init);
+ }
+
+ if (!name || name.length === 0) {
+ throw new Error("Invalid filename");
+ }
+
+ return this.post(opts?.path ?? ["upload", name], body, init);
+ }
+
+ async upload(
+ item: Request | Response | string | File | ReadableStream,
+ opts: {
+ filename?: string;
+ _init?: Omit;
+ path?: TInput;
+ } = {}
+ ) {
+ if (item instanceof Request || typeof item === "string") {
+ const res = await this.fetcher(item);
+ if (!res.ok || !res.body) {
+ throw new Error("Failed to fetch file");
+ }
+ return this.uploadFile(res.body, opts);
+ } else if (item instanceof Response) {
+ if (!item.body) {
+ throw new Error("Invalid response");
+ }
+ return this.uploadFile(item.body, {
+ ...(opts ?? {}),
+ _init: {
+ ...(opts._init ?? {}),
+ headers: {
+ ...(opts._init?.headers ?? {}),
+ "Content-Type": item.headers.get("Content-Type") || "application/octet-stream"
+ }
+ }
+ });
+ }
+
+ return this.uploadFile(item, opts);
+ }
+
+ async uploadToEntity(
+ entity: string,
+ id: PrimaryFieldType,
+ field: string,
+ item: Request | Response | string | File | ReadableStream,
+ opts?: {
+ _init?: Omit;
+ }
+ ) {
+ return this.upload(item, {
+ ...opts,
+ path: ["entity", entity, id, field]
+ });
}
deleteFile(filename: string) {
diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts
index e469830..4194b2b 100644
--- a/app/src/media/api/MediaController.ts
+++ b/app/src/media/api/MediaController.ts
@@ -1,6 +1,5 @@
-import { tbValidator as tb } from "core";
-import { Type } from "core/utils";
-import { bodyLimit } from "hono/body-limit";
+import { isDebug, tbValidator as tb } from "core";
+import { HttpStatus, Type, getFileFromContext } from "core/utils";
import type { StorageAdapter } from "media";
import { StorageEvents, getRandomizedFilename } from "media";
import { Controller } from "modules/Controller";
@@ -42,7 +41,6 @@ export class MediaController extends Controller {
if (!filename) {
throw new Error("No file name provided");
}
- //console.log("getting file", filename, headersToObject(c.req.raw.headers));
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
return await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
@@ -59,24 +57,40 @@ export class MediaController extends Controller {
return c.json({ message: "File deleted" });
});
- const uploadSizeMiddleware = bodyLimit({
- maxSize: this.getStorage().getConfig().body_max_size,
- onError: (c: any) => {
- return c.text(`Payload exceeds ${this.getStorage().getConfig().body_max_size}`, 413);
- }
- });
+ const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
+
+ if (isDebug()) {
+ hono.post("/inspect", async (c) => {
+ const file = await getFileFromContext(c);
+ return c.json({
+ type: file?.type,
+ name: file?.name,
+ size: file?.size
+ });
+ });
+ }
// upload file
// @todo: add required type for "upload endpoints"
- hono.post("/upload/:filename", uploadSizeMiddleware, async (c) => {
+ hono.post("/upload/:filename", async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
- const file = await this.getStorage().getFileFromRequest(c);
- console.log("----file", file);
- return c.json(await this.getStorage().uploadFile(file, filename));
+ const body = await getFileFromContext(c);
+ if (!body) {
+ return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
+ }
+ if (body.size > maxSize) {
+ return c.json(
+ { error: `Max size (${maxSize} bytes) exceeded` },
+ HttpStatus.PAYLOAD_TOO_LARGE
+ );
+ }
+
+ const res = await this.getStorage().uploadFile(body, filename);
+ return c.json(res, HttpStatus.CREATED);
});
// add upload file to entity
@@ -89,23 +103,21 @@ export class MediaController extends Controller {
overwrite: Type.Optional(booleanLike)
})
),
- uploadSizeMiddleware,
async (c) => {
const entity_name = c.req.param("entity");
const field_name = c.req.param("field");
const entity_id = Number.parseInt(c.req.param("id"));
- console.log("params", { entity_name, field_name, entity_id });
// check if entity exists
const entity = this.media.em.entity(entity_name);
if (!entity) {
- return c.json({ error: `Entity "${entity_name}" not found` }, 404);
+ return c.json({ error: `Entity "${entity_name}" not found` }, HttpStatus.NOT_FOUND);
}
// check if field exists and is of type MediaField
const field = entity.field(field_name);
if (!field || !(field instanceof MediaField)) {
- return c.json({ error: `Invalid field "${field_name}"` }, 400);
+ return c.json({ error: `Invalid field "${field_name}"` }, HttpStatus.BAD_REQUEST);
}
const media_entity = this.media.getMediaEntity().name as "media";
@@ -127,7 +139,10 @@ export class MediaController extends Controller {
if (count >= max_items) {
// if overwrite not set, abort early
if (!overwrite) {
- return c.json({ error: `Max items (${max_items}) reached` }, 400);
+ return c.json(
+ { error: `Max items (${max_items}) reached` },
+ HttpStatus.BAD_REQUEST
+ );
}
// if already more in database than allowed, abort early
@@ -135,7 +150,7 @@ export class MediaController extends Controller {
if (count > max_items) {
return c.json(
{ error: `Max items (${max_items}) exceeded already with ${count} items.` },
- 400
+ HttpStatus.UNPROCESSABLE_ENTITY
);
}
@@ -161,11 +176,21 @@ export class MediaController extends Controller {
if (!exists) {
return c.json(
{ error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` },
- 404
+ HttpStatus.NOT_FOUND
+ );
+ }
+
+ const file = await getFileFromContext(c);
+ if (!file) {
+ return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
+ }
+ if (file.size > maxSize) {
+ return c.json(
+ { error: `Max size (${maxSize} bytes) exceeded` },
+ HttpStatus.PAYLOAD_TOO_LARGE
);
}
- const file = await this.getStorage().getFileFromRequest(c);
const file_name = getRandomizedFilename(file as File);
const info = await this.getStorage().uploadFile(file, file_name, true);
@@ -185,10 +210,10 @@ export class MediaController extends Controller {
}
}
- return c.json({ ok: true, result: result.data, ...info });
+ return c.json({ ok: true, result: result.data, ...info }, HttpStatus.CREATED);
}
);
- return hono;
+ return hono.all("*", (c) => c.notFound());
}
}
diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts
index f196c79..229b108 100644
--- a/app/src/media/media-schema.ts
+++ b/app/src/media/media-schema.ts
@@ -16,8 +16,8 @@ export function buildMediaSchema() {
config: adapter.schema
},
{
- title: adapter.schema.title ?? name,
- description: adapter.schema.description,
+ title: adapter.schema?.title ?? name,
+ description: adapter.schema?.description,
additionalProperties: false
}
);
diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts
index 701319a..24be8f7 100644
--- a/app/src/media/storage/Storage.ts
+++ b/app/src/media/storage/Storage.ts
@@ -1,7 +1,6 @@
import { type EmitsEvents, EventManager } from "core/events";
-import type { TSchema } from "core/utils";
-import { type Context, Hono } from "hono";
-import { bodyLimit } from "hono/body-limit";
+import { type TSchema, isFile } from "core/utils";
+import { isMimeType } from "media/storage/mime-types-tiny";
import * as StorageEvents from "./events";
import type { FileUploadedEventData } from "./events";
@@ -12,7 +11,7 @@ export type FileListObject = {
};
export type FileMeta = { type: string; size: number };
-export type FileBody = ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob | File;
+export type FileBody = ReadableStream | File;
export type FileUploadPayload = {
name: string;
meta: FileMeta;
@@ -38,7 +37,7 @@ export interface StorageAdapter {
}
export type StorageConfig = {
- body_max_size: number;
+ body_max_size?: number;
};
export class Storage implements EmitsEvents {
@@ -55,7 +54,7 @@ export class Storage implements EmitsEvents {
this.#adapter = adapter;
this.config = {
...config,
- body_max_size: config.body_max_size ?? 20 * 1024 * 1024
+ body_max_size: config.body_max_size
};
this.emgr = emgr ?? new EventManager();
@@ -82,26 +81,33 @@ export class Storage implements EmitsEvents {
noEmit?: boolean
): Promise {
const result = await this.#adapter.putObject(name, file);
- console.log("result", result);
+ if (typeof result === "undefined") {
+ throw new Error("Failed to upload file");
+ }
- let info: FileUploadPayload;
+ let info: FileUploadPayload = {
+ name,
+ meta: {
+ size: 0,
+ type: "application/octet-stream"
+ },
+ etag: typeof result === "string" ? result : ""
+ };
- switch (typeof result) {
- case "undefined":
- throw new Error("Failed to upload file");
- case "string": {
- // get object meta
- const meta = await this.#adapter.getObjectMeta(name);
- if (!meta) {
- throw new Error("Failed to get object meta");
- }
+ if (typeof result === "object") {
+ info = result;
+ } else if (isFile(file)) {
+ info.meta.size = file.size;
+ info.meta.type = file.type;
+ }
- info = { name, meta, etag: result };
- break;
+ // try to get better meta info
+ if (!isMimeType(info?.meta.type, ["application/octet-stream", "application/json"])) {
+ const meta = await this.#adapter.getObjectMeta(name);
+ if (!meta) {
+ throw new Error("Failed to get object meta");
}
- case "object":
- info = result;
- break;
+ info.meta = meta;
}
const eventData = {
@@ -127,102 +133,4 @@ export class Storage implements EmitsEvents {
async fileExists(name: string) {
return await this.#adapter.objectExists(name);
}
-
- getController(): any {
- // @todo: multiple providers?
- // @todo: implement range requests
-
- const hono = new Hono();
-
- // get files list (temporary)
- hono.get("/files", async (c) => {
- const files = await this.#adapter.listObjects();
- return c.json(files);
- });
-
- // get file by name
- hono.get("/file/:filename", async (c) => {
- const { filename } = c.req.param();
- if (!filename) {
- throw new Error("No file name provided");
- }
- //console.log("getting file", filename, headersToObject(c.req.raw.headers));
-
- await this.emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
- return await this.#adapter.getObject(filename, c.req.raw.headers);
- });
-
- // delete a file by name
- hono.delete("/file/:filename", async (c) => {
- const { filename } = c.req.param();
- if (!filename) {
- throw new Error("No file name provided");
- }
- await this.deleteFile(filename);
-
- return c.json({ message: "File deleted" });
- });
-
- // upload file
- hono.post(
- "/upload/:filename",
- bodyLimit({
- maxSize: this.config.body_max_size,
- onError: (c: any) => {
- return c.text(`Payload exceeds ${this.config.body_max_size}`, 413);
- }
- }),
- async (c) => {
- const { filename } = c.req.param();
- if (!filename) {
- throw new Error("No file name provided");
- }
-
- const file = await this.getFileFromRequest(c);
- return c.json(await this.uploadFile(file, filename));
- }
- );
-
- return hono;
- }
-
- /**
- * If uploaded through HttpPie -> ReadableStream
- * If uploaded in tests -> file == ReadableStream
- * If uploaded in FE -> content_type:body multipart/form-data; boundary=----WebKitFormBoundary7euoBFF12B0AHWLn
- * file File {
- * size: 223052,
- * type: 'image/png',
- * name: 'noise_white.png',
- * lastModified: 1731743671176
- * }
- * @param c
- */
- async getFileFromRequest(c: Context): Promise {
- const content_type = c.req.header("Content-Type") ?? "application/octet-stream";
- console.log("content_type:body", content_type);
- const body = c.req.raw.body;
- if (!body) {
- throw new Error("No body");
- }
-
- let file: FileBody | undefined;
- if (content_type?.startsWith("multipart/form-data")) {
- file = (await c.req.formData()).get("file") as File;
- // @todo: check nextjs, it's not *that* [File] type (but it's uploadable)
- if (typeof file === "undefined") {
- throw new Error("No file given at form data 'file'");
- }
- /*console.log("file", file);
- if (!(file instanceof File)) {
- throw new Error("No file given at form data 'file'");
- }*/
- } else if (content_type?.startsWith("application/octet-stream")) {
- file = body;
- } else {
- throw new Error(`Unsupported content type: ${content_type}`);
- }
-
- return file;
- }
}
diff --git a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts
index cfb4100..7f2de8c 100644
--- a/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts
+++ b/app/src/media/storage/adapters/StorageCloudinaryAdapter.ts
@@ -13,12 +13,6 @@ export const cloudinaryAdapterConfig = Type.Object(
);
export type CloudinaryConfig = Static;
-/*export type CloudinaryConfig = {
- cloud_name: string;
- api_key: string;
- api_secret: string;
- upload_preset?: string;
-};*/
type CloudinaryObject = {
asset_id: string;
@@ -91,10 +85,8 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
}
async putObject(_key: string, body: FileBody) {
- //console.log("_key", _key);
// remove extension, as it is added by cloudinary
const key = _key.replace(/\.[a-z0-9]{2,5}$/, "");
- //console.log("key", key);
const formData = new FormData();
formData.append("file", body as any);
@@ -117,21 +109,12 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
body: formData
}
);
- //console.log("putObject:cloudinary", formData);
if (!result.ok) {
- /*console.log(
- "failed to upload using cloudinary",
- Object.fromEntries(formData.entries()),
- result
- );*/
return undefined;
}
- //console.log("putObject:result", result);
-
const data = (await result.json()) as CloudinaryPutObjectResponse;
- //console.log("putObject:result:json", data);
return {
name: data.public_id + "." + data.format,
@@ -154,7 +137,6 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
}
}
);
- //console.log("result", result);
if (!result.ok) {
throw new Error("Failed to list objects");
@@ -179,10 +161,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
}
async objectExists(key: string): Promise {
- //console.log("--object exists?", key);
const result = await this.headObject(key);
- //console.log("object exists", result);
-
return result.ok;
}
@@ -214,12 +193,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
const type = this.guessType(key) ?? "image";
const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`;
- //console.log("objectUrl", objectUrl);
return objectUrl;
}
async getObject(key: string, headers: Headers): Promise {
- //console.log("url", this.getObjectUrl(key));
const res = await fetch(this.getObjectUrl(key), {
method: "GET",
headers: pickHeaders(headers, ["range"])
@@ -237,14 +214,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
const formData = new FormData();
formData.append("public_ids[]", key);
- const result = await fetch(
- `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`,
- {
- method: "DELETE",
- body: formData
- }
- );
- //console.log("deleteObject:result", result);
+ await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, {
+ method: "DELETE",
+ body: formData
+ });
}
toJSON(secrets?: boolean) {
diff --git a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts
index 2c142ff..8b2f9ba 100644
--- a/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts
+++ b/app/src/media/storage/adapters/StorageLocalAdapter/StorageLocalAdapter.ts
@@ -1,6 +1,12 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
-import { type Static, Type, parse } from "core/utils";
-import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
+import { type Static, Type, isFile, parse } from "core/utils";
+import type {
+ FileBody,
+ FileListObject,
+ FileMeta,
+ FileUploadPayload,
+ StorageAdapter
+} from "../../Storage";
import { guess } from "../../mime-types-tiny";
export const localAdapterConfig = Type.Object(
@@ -43,8 +49,9 @@ export class StorageLocalAdapter implements StorageAdapter {
return fileStats;
}
- private async computeEtag(content: BufferSource): Promise {
- const hashBuffer = await crypto.subtle.digest("SHA-256", content);
+ private async computeEtag(body: FileBody): Promise {
+ const content = isFile(body) ? body : new Response(body);
+ const hashBuffer = await crypto.subtle.digest("SHA-256", await content.arrayBuffer());
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -52,17 +59,16 @@ export class StorageLocalAdapter implements StorageAdapter {
return `"${hashHex}"`;
}
- async putObject(key: string, body: FileBody): Promise {
+ async putObject(key: string, body: FileBody): Promise {
if (body === null) {
throw new Error("Body is empty");
}
- // @todo: this is too hacky
- const file = body as File;
-
const filePath = `${this.config.path}/${key}`;
- await writeFile(filePath, file.stream());
- return await this.computeEtag(await file.arrayBuffer());
+ const is_file = isFile(body);
+ await writeFile(filePath, is_file ? body.stream() : body);
+
+ return await this.computeEtag(body);
}
async deleteObject(key: string): Promise {
diff --git a/app/src/media/storage/adapters/StorageS3Adapter.ts b/app/src/media/storage/adapters/StorageS3Adapter.ts
index b330d64..cd051d7 100644
--- a/app/src/media/storage/adapters/StorageS3Adapter.ts
+++ b/app/src/media/storage/adapters/StorageS3Adapter.ts
@@ -7,7 +7,7 @@ import type {
PutObjectRequest
} from "@aws-sdk/client-s3";
import { AwsClient, isDebug } from "core";
-import { type Static, Type, parse, pickHeaders } from "core/utils";
+import { type Static, Type, isFile, parse, pickHeaders } from "core/utils";
import { transform } from "lodash-es";
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
@@ -82,17 +82,14 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
};
const url = this.getUrl("", params);
- //console.log("url", url);
const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
method: "GET"
});
- //console.log("res", res);
// absolutely weird, but if only one object is there, it's an object, not an array
const { Contents } = res.ListBucketResult;
const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents];
- //console.log(JSON.stringify(res.ListBucketResult, null, 2), objects);
const transformed = transform(
objects,
(acc, obj) => {
@@ -107,28 +104,21 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
},
[] as FileListObject[]
);
- //console.log(transformed);
return transformed;
}
async putObject(
key: string,
- body: FileBody | null,
+ body: FileBody,
// @todo: params must be added as headers, skipping for now
params: Omit = {}
) {
const url = this.getUrl(key, {});
- //console.log("url", url);
const res = await this.fetch(url, {
method: "PUT",
body
});
- /*console.log("putObject:raw:res", {
- ok: res.ok,
- status: res.status,
- statusText: res.statusText,
- });*/
if (res.ok) {
// "df20fcb574dba1446cf5ec997940492b"
diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts
index a231734..f54b51e 100644
--- a/app/src/media/storage/mime-types-tiny.ts
+++ b/app/src/media/storage/mime-types-tiny.ts
@@ -75,3 +75,21 @@ export function guess(f: string): string {
return c.a();
}
}
+
+export function isMimeType(mime: any, exclude: string[] = []) {
+ for (const [k, v] of M.entries()) {
+ if (v === mime && !exclude.includes(k)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+export function extension(mime: string) {
+ for (const [k, v] of M.entries()) {
+ if (v === mime) {
+ return k;
+ }
+ }
+ return "";
+}
diff --git a/app/src/media/utils/index.ts b/app/src/media/utils/index.ts
index a560c88..c042acc 100644
--- a/app/src/media/utils/index.ts
+++ b/app/src/media/utils/index.ts
@@ -17,5 +17,6 @@ export function getRandomizedFilename(file: File | string, length = 16): string
throw new Error("Invalid file name");
}
+ // @todo: use uuid instead?
return [randomString(length), getExtension(filename)].filter(Boolean).join(".");
}
diff --git a/app/src/modules/Module.ts b/app/src/modules/Module.ts
index ef0bc81..1488780 100644
--- a/app/src/modules/Module.ts
+++ b/app/src/modules/Module.ts
@@ -18,13 +18,14 @@ import { isEqual } from "lodash-es";
export type ServerEnv = {
Variables: {
- app?: App;
+ 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;
+ auth?: {
+ resolved: boolean;
+ registered: boolean;
+ skip: boolean;
+ user?: { id: any; role?: string; [key: string]: any };
+ };
html?: string;
};
};
diff --git a/app/src/modules/ModuleApi.ts b/app/src/modules/ModuleApi.ts
index a088170..f493382 100644
--- a/app/src/modules/ModuleApi.ts
+++ b/app/src/modules/ModuleApi.ts
@@ -9,6 +9,7 @@ export type BaseModuleApiOptions = {
token?: string;
headers?: Headers;
token_transport?: "header" | "cookie" | "none";
+ verbose?: boolean;
};
/** @deprecated */
@@ -23,10 +24,14 @@ export type ApiResponse = {
export type TInput = string | (string | number | PrimaryFieldType)[];
export abstract class ModuleApi {
+ protected fetcher: typeof fetch;
+
constructor(
protected readonly _options: Partial = {},
- protected fetcher?: typeof fetch
- ) {}
+ fetcher?: typeof fetch
+ ) {
+ this.fetcher = fetcher ?? fetch;
+ }
protected getDefaultOptions(): Partial {
return {};
@@ -76,7 +81,9 @@ export abstract class ModuleApi(
body: Body,
data?: Data
): ResponseObject {
- const actualData = data ?? (body as unknown as Data);
+ let actualData: any = data ?? body;
const _props = ["raw", "body", "ok", "status", "res", "data", "toJSON"];
- return new Proxy(actualData as any, {
+ // that's okay, since you have to check res.ok anyway
+ if (typeof actualData !== "object") {
+ actualData = {};
+ }
+
+ return new Proxy(actualData, {
get(target, prop, receiver) {
if (prop === "raw" || prop === "res") return raw;
if (prop === "body") return body;
@@ -208,15 +221,35 @@ export class FetchPromise> implements Promise {
public request: Request,
protected options?: {
fetcher?: typeof fetch;
+ verbose?: boolean;
}
) {}
+ get verbose() {
+ return this.options?.verbose ?? false;
+ }
+
async execute(): Promise> {
// delay in dev environment
isDebug() && (await new Promise((resolve) => setTimeout(resolve, 200)));
const fetcher = this.options?.fetcher ?? fetch;
+ if (this.verbose) {
+ console.log("[FetchPromise] Request", {
+ method: this.request.method,
+ url: this.request.url
+ });
+ }
+
const res = await fetcher(this.request);
+ if (this.verbose) {
+ console.log("[FetchPromise] Response", {
+ res: res,
+ ok: res.ok,
+ status: res.status
+ });
+ }
+
let resBody: any;
let resData: any;
@@ -228,7 +261,10 @@ export class FetchPromise> implements Promise {
}
} else if (contentType.startsWith("text")) {
resBody = await res.text();
+ } else {
+ resBody = res.body;
}
+ console.groupEnd();
return createResponseProxy(res, resBody, resData);
}
diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts
index 8019c50..ab9d412 100644
--- a/app/src/modules/ModuleManager.ts
+++ b/app/src/modules/ModuleManager.ts
@@ -1,5 +1,5 @@
import { Guard } from "auth";
-import { BkndError, DebugLogger } from "core";
+import { BkndError, DebugLogger, withDisabledConsole } from "core";
import { EventManager } from "core/events";
import { clone, diff } from "core/object/diff";
import {
@@ -10,8 +10,7 @@ import {
mark,
objectEach,
stripMark,
- transformObject,
- withDisabledConsole
+ transformObject
} from "core/utils";
import {
type Connection,
@@ -130,7 +129,7 @@ interface T_INTERNAL_EM {
// @todo: cleanup old diffs on upgrade
// @todo: cleanup multiple backups on upgrade
export class ModuleManager {
- private modules: Modules;
+ protected modules: Modules;
// internal em for __bknd config table
__em!: EntityManager;
// ctx for modules
@@ -433,44 +432,6 @@ export class ModuleManager {
});
}
- 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) {
- await this.modules[key].setContext(ctx).build();
- this.logger.log("built", key);
- }
-
- this._built = true;
- 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);
@@ -503,8 +464,10 @@ export class ModuleManager {
// 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();
+ const state = await this.buildModules();
+ if (!state.saved) {
+ await this.save();
+ }
// run initial setup
await this.setupInitial();
@@ -523,6 +486,60 @@ export class ModuleManager {
return this;
}
+ private async buildModules(options?: { graceful?: boolean; ignoreFlags?: boolean }) {
+ const state = {
+ built: false,
+ modules: [] as ModuleKey[],
+ synced: false,
+ saved: false,
+ reloaded: false
+ };
+
+ this.logger.log("buildModules() triggered", options, this._built);
+ if (options?.graceful && this._built) {
+ this.logger.log("skipping build (graceful)");
+ return state;
+ }
+
+ this.logger.log("building");
+ const ctx = this.ctx(true);
+ for (const key in this.modules) {
+ await this.modules[key].setContext(ctx).build();
+ this.logger.log("built", key);
+ state.modules.push(key as ModuleKey);
+ }
+
+ this._built = state.built = true;
+ this.logger.log("modules built", ctx.flags);
+
+ if (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 });
+ state.synced = true;
+
+ // save
+ await this.save();
+ state.saved = true;
+ }
+
+ if (ctx.flags.ctx_reload_required) {
+ ctx.flags.ctx_reload_required = false;
+ this.logger.log("ctx reload requested");
+ this.ctx(true);
+ state.reloaded = true;
+ }
+ }
+
+ // reset all falgs
+ ctx.flags = Module.ctx_flags;
+
+ return state;
+ }
+
protected async setupInitial() {
const ctx = {
...this.ctx(),
@@ -538,6 +555,56 @@ export class ModuleManager {
await this.options?.onFirstBoot?.();
}
+ mutateConfigSafe(
+ name: Module
+ ): Pick, "set" | "patch" | "overwrite" | "remove"> {
+ const module = this.modules[name];
+ const copy = structuredClone(this.configs());
+
+ return new Proxy(module.schema(), {
+ get: (target, prop: string) => {
+ if (!["set", "patch", "overwrite", "remove"].includes(prop)) {
+ throw new Error(`Method ${prop} is not allowed`);
+ }
+
+ return async (...args) => {
+ console.log("[Safe Mutate]", name);
+ try {
+ // overwrite listener to run build inside this try/catch
+ module.setListener(async () => {
+ await this.buildModules();
+ });
+
+ const result = await target[prop](...args);
+
+ // revert to original listener
+ module.setListener(async (c) => {
+ await this.onModuleConfigUpdated(name, c);
+ });
+
+ // if there was an onUpdated listener, call it after success
+ // e.g. App uses it to register module routes
+ if (this.options?.onUpdated) {
+ await this.options.onUpdated(name, module.config as any);
+ }
+
+ return result;
+ } catch (e) {
+ console.error("[Safe Mutate] failed", e);
+
+ // revert to previous config & rebuild using original listener
+ this.setConfigs(copy);
+ await this.onModuleConfigUpdated(name, module.config as any);
+ console.log("[Safe Mutate] reverted");
+
+ // make sure to throw the error
+ throw e;
+ }
+ };
+ }
+ });
+ }
+
get(key: K): Modules[K] {
if (!(key in this.modules)) {
throw new Error(`Module "${key}" doesn't exist, cannot get`);
diff --git a/app/src/modules/server/AdminController.tsx b/app/src/modules/server/AdminController.tsx
index b7fc900..7d48bb0 100644
--- a/app/src/modules/server/AdminController.tsx
+++ b/app/src/modules/server/AdminController.tsx
@@ -7,6 +7,7 @@ import { html } from "hono/html";
import { Fragment } from "hono/jsx";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
+import type { AppTheme } from "modules/server/AppServer";
const htmlBkndContextReplace = "";
@@ -16,6 +17,7 @@ export type AdminControllerOptions = {
assets_path?: string;
html?: string;
forceDev?: boolean | { mainPath: string };
+ debug_rerenders?: boolean;
};
export class AdminController extends Controller {
@@ -69,7 +71,7 @@ export class AdminController extends Controller {
hono.use("*", async (c, next) => {
const obj = {
- user: auth.authenticator?.getUser(),
+ user: c.get("auth")?.user,
logout_route: this.withBasePath(authRoutes.logout),
color_scheme: configs.server.admin.color_scheme
};
@@ -91,7 +93,7 @@ export class AdminController extends Controller {
// @ts-ignore
onGranted: async (c) => {
// @todo: add strict test to permissions middleware?
- if (auth.authenticator.isUserLoggedIn()) {
+ if (c.get("auth")?.user) {
console.log("redirecting to success");
return c.redirect(authRoutes.success);
}
@@ -162,17 +164,13 @@ export class AdminController extends Controller {
};
if (isProd) {
- try {
- // @ts-ignore
- const manifest = await import("bknd/dist/manifest.json", {
- assert: { type: "json" }
- }).then((m) => m.default);
- // @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);
- }
+ // @ts-ignore
+ const manifest = await import("bknd/dist/manifest.json", {
+ assert: { type: "json" }
+ });
+ // @todo: load all marked as entry (incl. css)
+ assets.js = manifest.default["src/ui/main.tsx"].file;
+ assets.css = manifest.default["src/ui/main.tsx"].css[0] as any;
}
const theme = configs.server.admin.color_scheme ?? "light";
@@ -191,10 +189,12 @@ export class AdminController extends Controller {
/>
BKND
- {/**/}
+ {this.options.debug_rerenders && (
+
+ )}
{isProd ? (