mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ packages/media/.env
|
||||
.history
|
||||
**/*/.db/*
|
||||
**/*/.configs/*
|
||||
**/*/.template/*
|
||||
**/*/*.db
|
||||
**/*/*.db-shm
|
||||
**/*/*.db-wal
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
1
app/__test__/_assets/.gitignore
vendored
Normal file
1
app/__test__/_assets/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tmp/*
|
||||
BIN
app/__test__/_assets/image.png
Normal file
BIN
app/__test__/_assets/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -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);
|
||||
}
|
||||
|
||||
146
app/__test__/api/MediaApi.spec.ts
Normal file
146
app/__test__/api/MediaApi.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/// <reference types="@types/bun" />
|
||||
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<any>, 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"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<EntityData>;
|
||||
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<EntityData>;
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -68,3 +68,6 @@ export function schemaToEm(s: ReturnType<typeof protoEm>, 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`;
|
||||
|
||||
@@ -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 = <Mode extends "cookie" | "token" = "token">(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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
24
app/__test__/integration/config.integration.test.ts
Normal file
24
app/__test__/integration/config.integration.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
/// <reference types="@types/bun" />
|
||||
|
||||
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<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | 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<TAppMediaConfig> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof failingModuleSchema> {
|
||||
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<typeof ModuleManager>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
262
app/build.ts
262
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> = {}): 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();
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
||||
};
|
||||
export type CreateAppConfig = {
|
||||
connection?:
|
||||
| Connection
|
||||
| {
|
||||
// @deprecated
|
||||
type: "libsql";
|
||||
config: LibSqlCredentials;
|
||||
};
|
||||
}
|
||||
| LibSqlCredentials;
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
plugins?: AppPlugin[];
|
||||
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
|
||||
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 extends keyof Modules>(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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
app/src/adapter/cloudflare/D1Connection.ts
Normal file
63
app/src/adapter/cloudflare/D1Connection.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
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<any>): 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 extends QB[]>(
|
||||
queries: [...Queries]
|
||||
): Promise<{
|
||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||
}> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>) {
|
||||
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<Response> {
|
||||
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
|
||||
};
|
||||
}
|
||||
32
app/src/adapter/cloudflare/bindings.ts
Normal file
32
app/src/adapter/cloudflare/bindings.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type BindingTypeMap = {
|
||||
D1Database: D1Database;
|
||||
KVNamespace: KVNamespace;
|
||||
DurableObjectNamespace: DurableObjectNamespace;
|
||||
R2Bucket: R2Bucket;
|
||||
};
|
||||
|
||||
export type GetBindingType = keyof BindingTypeMap;
|
||||
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
||||
|
||||
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
||||
const bindings: BindingMap<T>[] = [];
|
||||
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<T extends GetBindingType>(env: any, type: T): BindingMap<T> {
|
||||
const bindings = getBindings(env, type);
|
||||
if (bindings.length === 0) {
|
||||
throw new Error(`No ${type} found in bindings`);
|
||||
}
|
||||
return bindings[0] as BindingMap<T>;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { FrameworkBkndConfig } from "bknd/adapter";
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
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<Env = any> = FrameworkBkndConfig<Context<Env>>
|
||||
bindings?: (args: Context<Env>) => {
|
||||
kv?: KVNamespace;
|
||||
dobj?: DurableObjectNamespace;
|
||||
db?: D1Database;
|
||||
};
|
||||
static?: "kv" | "assets";
|
||||
key?: string;
|
||||
@@ -26,7 +32,39 @@ export type Context<Env = any> = {
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
|
||||
export function serve<Env = any>(config: CloudflareBkndConfig<Env>) {
|
||||
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<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
||||
return {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
@@ -61,8 +99,6 @@ export function serve<Env = any>(config: CloudflareBkndConfig<Env>) {
|
||||
}
|
||||
}
|
||||
|
||||
config.setAdminHtml = config.setAdminHtml && !!config.manifest;
|
||||
|
||||
const context = { request, env, ctx } as Context;
|
||||
const mode = config.mode ?? "warm";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
|
||||
|
||||
@@ -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<Args extends RemixContext = RemixContext>(
|
||||
config: RemixBkndConfig<Args> = {}
|
||||
) {
|
||||
return async (args: Args) => {
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config, args);
|
||||
}
|
||||
app = await createFrameworkApp(config, args);
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
}
|
||||
|
||||
export function withApi<Args extends { request: Request; context: { api: Api } }, R>(
|
||||
handler: (args: Args, api: Api) => Promise<R>
|
||||
) {
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,17 +106,15 @@ export type AuthUserResolver = (
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) => Promise<SafeUser | undefined>;
|
||||
type AuthClaims = SafeUser & {
|
||||
iat: number;
|
||||
iss?: string;
|
||||
exp?: number;
|
||||
};
|
||||
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
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<Strategies extends Record<string, Strategy> = 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<Strategies extends Record<string, Strategy> = Record<
|
||||
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
|
||||
}
|
||||
|
||||
async verify(jwt: string): Promise<boolean> {
|
||||
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
||||
try {
|
||||
const payload = await verify(
|
||||
jwt,
|
||||
@@ -221,14 +204,10 @@ export class Authenticator<Strategies extends Record<string, Strategy> = 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<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
async requestCookieRefresh(c: Context) {
|
||||
if (this.config.cookie.renew && this.isUserLoggedIn()) {
|
||||
async requestCookieRefresh(c: Context<ServerEnv>) {
|
||||
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<Strategies extends Record<string, Strategy> = Record<
|
||||
await deleteCookie(c, "auth", this.cookieOptions);
|
||||
}
|
||||
|
||||
async logout(c: Context) {
|
||||
async logout(c: Context<ServerEnv>) {
|
||||
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<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await this.verify(token);
|
||||
return this.getUser();
|
||||
return await this.verify(token);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -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<ServerEnv> | 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
|
||||
|
||||
@@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) {
|
||||
}
|
||||
|
||||
export function shouldSkip(c: Context<ServerEnv>, 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<ServerEnv>, 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<ServerEnv>(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<ServerEnv>(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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
294
app/src/cli/commands/create/create.ts
Normal file
294
app/src/cli/commands/create/create.ts
Normal file
@@ -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>", "integration to use"))
|
||||
.addOption(new Option("-t, --template <template>", "template to use"))
|
||||
.addOption(new Option("-d --dir <directory>", "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."));
|
||||
}
|
||||
1
app/src/cli/commands/create/index.ts
Normal file
1
app/src/cli/commands/create/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./create";
|
||||
83
app/src/cli/commands/create/npm.ts
Normal file
83
app/src/cli/commands/create/npm.ts
Normal file
@@ -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<string, string>;
|
||||
dependencies: Record<string, string | undefined>;
|
||||
devDependencies: Record<string, string | undefined>;
|
||||
optionalDependencies: Record<string, string | undefined>;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
|
||||
export async function overrideJson<File extends object = object>(
|
||||
file: string,
|
||||
fn: (pkg: File) => Promise<File> | 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> | TPackageJson,
|
||||
opts?: { dir?: string }
|
||||
) {
|
||||
return await overrideJson("package.json", fn, { dir: opts?.dir });
|
||||
}
|
||||
|
||||
export async function getPackageInfo(pkg: string, version?: string): Promise<TPackageJson> {
|
||||
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> | 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<string, string>) {
|
||||
const versions = {
|
||||
bknd: "^" + (await sysGetVersion()),
|
||||
...(map ?? {})
|
||||
};
|
||||
await replacePackageJsonVersions(
|
||||
async (pkg) => {
|
||||
if (pkg in versions) {
|
||||
return versions[pkg];
|
||||
}
|
||||
return;
|
||||
},
|
||||
{ dir }
|
||||
);
|
||||
}
|
||||
144
app/src/cli/commands/create/templates/cloudflare.ts
Normal file
144
app/src/cli/commands/create/templates/cloudflare.ts
Normal file
@@ -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 <name>")} 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
|
||||
);
|
||||
})()
|
||||
);
|
||||
}
|
||||
64
app/src/cli/commands/create/templates/index.ts
Normal file
64
app/src/cli/commands/create/templates/index.ts
Normal file
@@ -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<Record<TemplateScripts, string>>;
|
||||
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||
setup?: (ctx: TemplateSetupCtx) => Promise<void>;
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
];
|
||||
29
app/src/cli/commands/create/templates/nextjs.ts
Normal file
29
app/src/cli/commands/create/templates/nextjs.ts
Normal file
@@ -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;
|
||||
25
app/src/cli/commands/create/templates/remix.ts
Normal file
25
app/src/cli/commands/create/templates/remix.ts
Normal file
@@ -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;
|
||||
@@ -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>", "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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export { schema } from "./schema";
|
||||
export { run } from "./run";
|
||||
export { debug } from "./debug";
|
||||
export { user } from "./user";
|
||||
export { create } from "./create";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>", "config file"))
|
||||
.addOption(
|
||||
new Option("--db-url <db>", "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 });
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
56
app/src/cli/utils/cli.ts
Normal file
56
app/src/cli/utils/cli.ts
Normal file
@@ -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: <explanation>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string, string> }) {
|
||||
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<string, string> }
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
nodeExec(
|
||||
command,
|
||||
{
|
||||
env: { ...process.env, ...opts?.env }
|
||||
},
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(stdout);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
105
app/src/core/console.ts
Normal file
105
app/src/core/console.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import colors from "picocolors";
|
||||
|
||||
function hasColors() {
|
||||
try {
|
||||
// biome-ignore lint/style/useSingleVarDeclarator: <explanation>
|
||||
const p = process || {},
|
||||
argv = p.argv || [],
|
||||
env = p.env || {};
|
||||
return (
|
||||
!(!!env.NO_COLOR || argv.includes("--no-color")) &&
|
||||
// biome-ignore lint/complexity/useOptionalChain: <explanation>
|
||||
(!!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<R>(
|
||||
fn: () => Promise<R>,
|
||||
sev?: TConsoleSeverity[]
|
||||
): Promise<R> {
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,17 @@ export class EventListener<E extends Event = Event> {
|
||||
event: EventClass;
|
||||
handler: ListenerHandler<E>;
|
||||
once: boolean = false;
|
||||
id?: string;
|
||||
|
||||
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
|
||||
constructor(
|
||||
event: EventClass,
|
||||
handler: ListenerHandler<E>,
|
||||
mode: ListenerMode = "async",
|
||||
id?: string
|
||||
) {
|
||||
this.event = event;
|
||||
this.handler = handler;
|
||||
this.mode = mode;
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export {
|
||||
} from "./object/query/query";
|
||||
export { Registry, type Constructor } from "./registry/Registry";
|
||||
|
||||
export * from "./console";
|
||||
|
||||
// compatibility
|
||||
export type Middleware = MiddlewareHandler<any, any, any>;
|
||||
export interface ClassController {
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, string> = {
|
||||
"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<string | undefined> {
|
||||
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<File> {
|
||||
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<any>): Promise<File> {
|
||||
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<any>): Promise<ReadableStream | File> {
|
||||
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: <explanation>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<RepoQuery>;
|
||||
defaultQuery: Partial<RepoQueryIn>;
|
||||
};
|
||||
|
||||
export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
@@ -23,7 +23,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
id: PrimaryFieldType,
|
||||
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
|
||||
) {
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
|
||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
||||
["entity", entity as any, id],
|
||||
query
|
||||
);
|
||||
}
|
||||
|
||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||
|
||||
const input = query ?? this.options.defaultQuery;
|
||||
const req = this.get<T>([entity as any], input);
|
||||
const req = this.get<T>(["entity", entity as any], input);
|
||||
|
||||
if (req.request.url.length <= this.options.queryLengthLimit) {
|
||||
return req;
|
||||
}
|
||||
|
||||
return this.post<T>([entity as any, "query"], input);
|
||||
return this.post<T>(["entity", entity as any, "query"], input);
|
||||
}
|
||||
|
||||
readManyByReference<
|
||||
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
Data = R extends keyof DB ? DB[R] : EntityData
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "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<DataApiOptions> {
|
||||
entity: E,
|
||||
input: Omit<Data, "id">
|
||||
) {
|
||||
return this.post<RepositoryResponse<Data>>([entity as any], input);
|
||||
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
|
||||
}
|
||||
|
||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
id: PrimaryFieldType,
|
||||
input: Partial<Omit<Data, "id">>
|
||||
) {
|
||||
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
|
||||
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
|
||||
}
|
||||
|
||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||
entity: E,
|
||||
id: PrimaryFieldType
|
||||
) {
|
||||
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
|
||||
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
|
||||
}
|
||||
|
||||
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
|
||||
count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
|
||||
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
|
||||
[entity as any, "fn", "count"],
|
||||
["entity", entity as any, "fn", "count"],
|
||||
where
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<TableMetadata[]> {
|
||||
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<DatabaseMetadata> {
|
||||
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
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -107,18 +107,24 @@ export class Mutator<
|
||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||
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<MutatorResponse<EntityData>> {
|
||||
|
||||
@@ -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<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect()
|
||||
};
|
||||
//console.log("validated", validated);
|
||||
|
||||
if (!options) return validated;
|
||||
|
||||
@@ -144,7 +144,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
});
|
||||
|
||||
if (invalid.length > 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<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||
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<number>().as(as);
|
||||
@@ -179,7 +181,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
totalQuery,
|
||||
qb
|
||||
]);
|
||||
//console.log("result", { _count, _total });
|
||||
//$console.log("result", { _count, _total });
|
||||
|
||||
const time = Number.parseFloat((performance.now() - start).toFixed(2));
|
||||
const data = this.em.hydrate(entity.name, result);
|
||||
@@ -200,7 +202,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.error("[ERROR] Repository.performQuery", e.message);
|
||||
$console.error("[ERROR] Repository.performQuery", e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
@@ -253,7 +255,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
...config?.defaults
|
||||
};
|
||||
|
||||
/*console.log("build query options", {
|
||||
/*$console.log("build query options", {
|
||||
entity: entity.name,
|
||||
options,
|
||||
config
|
||||
@@ -334,7 +336,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
|
||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
|
||||
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<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
}
|
||||
};
|
||||
|
||||
//console.log("findManyOptions", newEntity.name, findManyOptions);
|
||||
return this.cloneFor(newEntity).findMany(findManyOptions);
|
||||
}
|
||||
|
||||
@@ -427,9 +427,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
qb = qb.limit(1);
|
||||
|
||||
const compiled = qb.compile();
|
||||
//console.log("exists query", compiled.sql, compiled.parameters);
|
||||
//$console.log("exists query", compiled.sql, compiled.parameters);
|
||||
const result = await qb.execute();
|
||||
//console.log("result", result);
|
||||
//$console.log("result", result);
|
||||
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
|
||||
@@ -37,7 +37,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
}
|
||||
|
||||
override async transformPersist(value: any): Promise<number> {
|
||||
throw new Error("This function should not be called");
|
||||
throw new Error("PrimaryField: This function should not be called");
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -105,13 +105,13 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
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<typeof ManyToManyRelation
|
||||
.whereRef(entityRef, "=", otherRef)
|
||||
.innerJoin(...join)
|
||||
.limit(limit);
|
||||
|
||||
/*return qb.select((eb) => {
|
||||
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<any>) {
|
||||
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 {
|
||||
|
||||
@@ -88,7 +88,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
}
|
||||
|
||||
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
|
||||
throw new Error("This function should not be called");
|
||||
throw new Error("RelationField: This function should not be called");
|
||||
}
|
||||
|
||||
override toJsonSchema() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
|
||||
});
|
||||
});
|
||||
|
||||
hono.all("*", (c) => c.notFound());
|
||||
|
||||
this.ctx.server.route(this.config.basepath, hono);
|
||||
|
||||
// register flows
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -40,7 +40,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
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<typeof mediaConfigSchema> {
|
||||
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<typeof mediaConfigSchema> {
|
||||
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<typeof mediaConfigSchema> {
|
||||
|
||||
console.log("App:storage:file deleted", e);
|
||||
},
|
||||
"sync"
|
||||
{ mode: "sync", id: "delete-data-media" }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<MediaApiOptions> {
|
||||
};
|
||||
}
|
||||
|
||||
getFiles() {
|
||||
return this.get(["files"]);
|
||||
listFiles() {
|
||||
return this.get<FileListObject[]>(["files"]);
|
||||
}
|
||||
|
||||
getFile(filename: string) {
|
||||
return this.get(["file", filename]);
|
||||
return this.get<ReadableStream<Uint8Array>>(["file", filename], undefined, {
|
||||
headers: {
|
||||
Accept: "*/*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getFileStream(filename: string): Promise<ReadableStream<Uint8Array>> {
|
||||
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<File> {
|
||||
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<MediaApiOptions> {
|
||||
});
|
||||
}
|
||||
|
||||
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<RequestInit, "body">;
|
||||
}
|
||||
) {
|
||||
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<RequestInit, "body">;
|
||||
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<RequestInit, "body">;
|
||||
}
|
||||
) {
|
||||
return this.upload(item, {
|
||||
...opts,
|
||||
path: ["entity", entity, id, field]
|
||||
});
|
||||
}
|
||||
|
||||
deleteFile(filename: string) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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<FileUploadedEventData> {
|
||||
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<FileBody> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,6 @@ export const cloudinaryAdapterConfig = Type.Object(
|
||||
);
|
||||
|
||||
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
||||
/*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<boolean> {
|
||||
//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<Response> {
|
||||
//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) {
|
||||
|
||||
@@ -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<string> {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
|
||||
private async computeEtag(body: FileBody): Promise<string> {
|
||||
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<string> {
|
||||
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
|
||||
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<void> {
|
||||
|
||||
@@ -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<PutObjectRequest, "Bucket" | "Key"> = {}
|
||||
) {
|
||||
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"
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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(".");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<Data = any> = {
|
||||
export type TInput = string | (string | number | PrimaryFieldType)[];
|
||||
|
||||
export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModuleApiOptions> {
|
||||
protected fetcher: typeof fetch;
|
||||
|
||||
constructor(
|
||||
protected readonly _options: Partial<Options> = {},
|
||||
protected fetcher?: typeof fetch
|
||||
) {}
|
||||
fetcher?: typeof fetch
|
||||
) {
|
||||
this.fetcher = fetcher ?? fetch;
|
||||
}
|
||||
|
||||
protected getDefaultOptions(): Partial<Options> {
|
||||
return {};
|
||||
@@ -76,7 +81,9 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
|
||||
headers.set(key, value as string);
|
||||
}
|
||||
|
||||
headers.set("Accept", "application/json");
|
||||
if (!headers.has("Accept")) {
|
||||
headers.set("Accept", "application/json");
|
||||
}
|
||||
|
||||
// only add token if initial headers not provided
|
||||
if (this.options.token && this.options.token_transport === "header") {
|
||||
@@ -101,7 +108,8 @@ export abstract class ModuleApi<Options extends BaseModuleApiOptions = BaseModul
|
||||
});
|
||||
|
||||
return new FetchPromise(request, {
|
||||
fetcher: this.fetcher
|
||||
fetcher: this.fetcher,
|
||||
verbose: this.options.verbose
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,10 +171,15 @@ export function createResponseProxy<Body = any, Data = any>(
|
||||
body: Body,
|
||||
data?: Data
|
||||
): ResponseObject<Body, Data> {
|
||||
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<T = ApiResponse<any>> implements Promise<T> {
|
||||
public request: Request,
|
||||
protected options?: {
|
||||
fetcher?: typeof fetch;
|
||||
verbose?: boolean;
|
||||
}
|
||||
) {}
|
||||
|
||||
get verbose() {
|
||||
return this.options?.verbose ?? false;
|
||||
}
|
||||
|
||||
async execute(): Promise<ResponseObject<T>> {
|
||||
// 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<T = ApiResponse<any>> implements Promise<T> {
|
||||
}
|
||||
} else if (contentType.startsWith("text")) {
|
||||
resBody = await res.text();
|
||||
} else {
|
||||
resBody = res.body;
|
||||
}
|
||||
console.groupEnd();
|
||||
|
||||
return createResponseProxy<T>(res, resBody, resData);
|
||||
}
|
||||
|
||||
@@ -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<T_INTERNAL_EM>;
|
||||
// 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<Module extends keyof Modules>(
|
||||
name: Module
|
||||
): Pick<ReturnType<Modules[Module]["schema"]>, "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<K extends keyof Modules>(key: K): Modules[K] {
|
||||
if (!(key in this.modules)) {
|
||||
throw new Error(`Module "${key}" doesn't exist, cannot get`);
|
||||
|
||||
@@ -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 = "<!-- BKND_CONTEXT -->";
|
||||
|
||||
@@ -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 {
|
||||
/>
|
||||
<link rel="icon" href={favicon} type="image/x-icon" />
|
||||
<title>BKND</title>
|
||||
{/*<script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
/>*/}
|
||||
{this.options.debug_rerenders && (
|
||||
<script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
/>
|
||||
)}
|
||||
{isProd ? (
|
||||
<Fragment>
|
||||
<script
|
||||
@@ -246,7 +246,7 @@ export class AdminController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
const style = (theme: "light" | "dark" = "light") => {
|
||||
const style = (theme: AppTheme) => {
|
||||
const base = {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
@@ -271,6 +271,6 @@ const style = (theme: "light" | "dark" = "light") => {
|
||||
|
||||
return {
|
||||
...base,
|
||||
...styles[theme]
|
||||
...styles[theme === "light" ? "light" : "dark"]
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Exception, isDebug } from "core";
|
||||
import { Exception } from "core";
|
||||
import { type Static, StringEnum, Type } from "core/utils";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { timing } from "hono/timing";
|
||||
import { Module } from "modules/Module";
|
||||
import * as SystemPermissions from "modules/permissions";
|
||||
|
||||
const serverMethods = ["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||
const appThemes = ["dark", "light", "system"] as const;
|
||||
export type AppTheme = (typeof appThemes)[number];
|
||||
|
||||
export const serverConfigSchema = Type.Object(
|
||||
{
|
||||
admin: Type.Object(
|
||||
{
|
||||
basepath: Type.Optional(Type.String({ default: "", pattern: "^(/.+)?$" })),
|
||||
color_scheme: Type.Optional(StringEnum(["dark", "light"], { default: "light" })),
|
||||
color_scheme: Type.Optional(StringEnum(["dark", "light", "system"])),
|
||||
logo_return_path: Type.Optional(
|
||||
Type.String({
|
||||
default: "/",
|
||||
|
||||
@@ -40,6 +40,7 @@ export class SystemController extends Controller {
|
||||
|
||||
private registerConfigController(client: Hono<any>): void {
|
||||
const { permission } = this.middlewares;
|
||||
// don't add auth again, it's already added in getController
|
||||
const hono = this.create();
|
||||
|
||||
hono.use(permission(SystemPermissions.configRead));
|
||||
@@ -63,7 +64,7 @@ export class SystemController extends Controller {
|
||||
const { secrets } = c.req.valid("query");
|
||||
const { module } = c.req.valid("param");
|
||||
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
|
||||
const config = this.app.toJSON(secrets);
|
||||
|
||||
@@ -141,14 +142,13 @@ export class SystemController extends Controller {
|
||||
const value = await c.req.json();
|
||||
const path = c.req.param("path") as string;
|
||||
|
||||
const moduleConfig = this.app.mutateConfig(module);
|
||||
if (moduleConfig.has(path)) {
|
||||
if (this.app.modules.get(module).schema().has(path)) {
|
||||
return c.json({ success: false, path, error: "Path already exists" }, { status: 400 });
|
||||
}
|
||||
console.log("-- add", module, path, value);
|
||||
|
||||
return await handleConfigUpdateResponse(c, async () => {
|
||||
await moduleConfig.patch(path, value);
|
||||
await this.app.mutateConfig(module).patch(path, value);
|
||||
return {
|
||||
success: true,
|
||||
module,
|
||||
@@ -227,8 +227,8 @@ export class SystemController extends Controller {
|
||||
const module = c.req.param("module") as ModuleKey | undefined;
|
||||
const { config, secrets } = c.req.valid("query");
|
||||
|
||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets);
|
||||
config && this.ctx.guard.throwUnlessGranted(SystemPermissions.configRead, c);
|
||||
secrets && this.ctx.guard.throwUnlessGranted(SystemPermissions.configReadSecrets, c);
|
||||
|
||||
const { version, ...schema } = this.app.getSchema();
|
||||
|
||||
@@ -256,80 +256,32 @@ export class SystemController extends Controller {
|
||||
tb(
|
||||
"query",
|
||||
Type.Object({
|
||||
sync: Type.Optional(booleanLike),
|
||||
drop: Type.Optional(booleanLike),
|
||||
save: Type.Optional(booleanLike)
|
||||
sync: Type.Optional(booleanLike)
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { sync, drop, save } = c.req.valid("query") as Record<string, boolean>;
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build);
|
||||
const { sync } = c.req.valid("query") as Record<string, boolean>;
|
||||
this.ctx.guard.throwUnlessGranted(SystemPermissions.build, c);
|
||||
|
||||
await this.app.build({ sync, drop, save });
|
||||
return c.json({ success: true, options: { sync, drop, save } });
|
||||
await this.app.build({ sync });
|
||||
return c.json({ success: true, options: { sync } });
|
||||
}
|
||||
);
|
||||
|
||||
hono.get("/ping", async (c) => {
|
||||
//console.log("c", c);
|
||||
try {
|
||||
// @ts-ignore @todo: fix with env
|
||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||
const cf = {
|
||||
colo: context.colo,
|
||||
city: context.city,
|
||||
postal: context.postalCode,
|
||||
region: context.region,
|
||||
regionCode: context.regionCode,
|
||||
continent: context.continent,
|
||||
country: context.country,
|
||||
eu: context.isEUCountry,
|
||||
lat: context.latitude,
|
||||
lng: context.longitude,
|
||||
timezone: context.timezone
|
||||
};
|
||||
return c.json({ pong: true });
|
||||
} catch (e) {
|
||||
return c.json({ pong: true });
|
||||
}
|
||||
});
|
||||
hono.get("/ping", (c) => c.json({ pong: true }));
|
||||
|
||||
hono.get("/info", async (c) => {
|
||||
return c.json({
|
||||
version: this.app.version(),
|
||||
test: 2,
|
||||
app: c.get("app")?.version(),
|
||||
hono.get("/info", (c) =>
|
||||
c.json({
|
||||
version: c.get("app")?.version(),
|
||||
runtime: getRuntimeKey()
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
hono.get("/openapi.json", async (c) => {
|
||||
//const config = this.app.toJSON();
|
||||
const config = getDefaultConfig();
|
||||
return c.json(generateOpenAPI(config));
|
||||
});
|
||||
|
||||
/*hono.get("/test/sql", async (c) => {
|
||||
// @ts-ignore
|
||||
const ai = c.env?.AI as Ai;
|
||||
const messages = [
|
||||
{ role: "system", content: "You are a friendly assistant" },
|
||||
{
|
||||
role: "user",
|
||||
content: "just say hello"
|
||||
}
|
||||
];
|
||||
|
||||
const stream = await ai.run("@cf/meta/llama-3.1-8b-instruct", {
|
||||
messages,
|
||||
stream: true
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { "content-type": "text/event-stream" }
|
||||
});
|
||||
});*/
|
||||
|
||||
return hono;
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ function dataRoutes(config: ModuleConfigs): { paths: OAS.Document["paths"] } {
|
||||
|
||||
const tags = ["data"];
|
||||
const paths: OAS.PathsObject = {
|
||||
"/{entity}": {
|
||||
"/entity/{entity}": {
|
||||
get: {
|
||||
summary: "List entities",
|
||||
parameters: [params.entity],
|
||||
@@ -148,7 +148,7 @@ function dataRoutes(config: ModuleConfigs): { paths: OAS.Document["paths"] } {
|
||||
tags
|
||||
}
|
||||
},
|
||||
"/{entity}/{id}": {
|
||||
"/entity/{entity}/{id}": {
|
||||
get: {
|
||||
summary: "Get entity",
|
||||
parameters: [params.entity, params.entityId],
|
||||
|
||||
@@ -44,7 +44,7 @@ function AdminInternal() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<MantineProvider {...createMantineTheme(theme ?? "light")}>
|
||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||
<Notifications />
|
||||
<FlashMessage />
|
||||
<BkndModalsProvider>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Api, type ApiOptions, type TApiUser } from "Api";
|
||||
import { isDebug } from "core";
|
||||
import type { AppTheme } from "modules/server/AppServer";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const ClientContext = createContext<{ baseUrl: string; api: Api }>({
|
||||
@@ -23,15 +25,15 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
|
||||
console.warn("wrapped many times, take from context", actualBaseUrl);
|
||||
} else if (typeof window !== "undefined") {
|
||||
actualBaseUrl = window.location.origin;
|
||||
console.log("setting from window", actualBaseUrl);
|
||||
//console.log("setting from window", actualBaseUrl);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("error .....", e);
|
||||
console.error("Error in ClientProvider", e);
|
||||
}
|
||||
|
||||
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user });
|
||||
const api = new Api({ host: actualBaseUrl, user: user ?? winCtx.user, verbose: isDebug() });
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={{ baseUrl: api.baseUrl, api }}>
|
||||
@@ -60,7 +62,7 @@ export const useBaseUrl = () => {
|
||||
type BkndWindowContext = {
|
||||
user?: TApiUser;
|
||||
logout_route: string;
|
||||
color_scheme?: "light" | "dark";
|
||||
color_scheme?: AppTheme;
|
||||
};
|
||||
export function useBkndWindowContext(): BkndWindowContext {
|
||||
if (typeof window !== "undefined" && window.__BKND__) {
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./api/use-api";
|
||||
export * from "./api/use-entity";
|
||||
export { useAuth } from "./schema/auth/use-auth";
|
||||
export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api";
|
||||
export type { RepoQueryIn } from "data";
|
||||
|
||||
@@ -7,7 +7,12 @@ export function useBkndMedia() {
|
||||
const actions = {
|
||||
config: {
|
||||
patch: async (data: Partial<TAppMediaConfig>) => {
|
||||
return await bkndActions.set("media", data, true);
|
||||
if (await bkndActions.set("media", data, true)) {
|
||||
await bkndActions.reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import type { AppTheme } from "modules/server/AppServer";
|
||||
import { useBkndWindowContext } from "ui/client/ClientProvider";
|
||||
import { useBknd } from "ui/client/bknd";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export function useTheme(fallback: Theme = "light"): { theme: Theme } {
|
||||
export function useTheme(fallback: AppTheme = "system"): { theme: AppTheme } {
|
||||
const b = useBknd();
|
||||
const winCtx = useBkndWindowContext();
|
||||
if (b) {
|
||||
if (b?.adminOverride?.color_scheme) {
|
||||
return { theme: b.adminOverride.color_scheme };
|
||||
} else if (!b.fallback) {
|
||||
return { theme: b.config.server.admin.color_scheme ?? fallback };
|
||||
}
|
||||
|
||||
// 1. override
|
||||
// 2. config
|
||||
// 3. winCtx
|
||||
// 4. fallback
|
||||
// 5. default
|
||||
const override = b?.adminOverride?.color_scheme;
|
||||
const config = b?.config.server.admin.color_scheme;
|
||||
const win = winCtx.color_scheme;
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
const theme = override ?? config ?? win ?? fallback;
|
||||
|
||||
if (theme === "system") {
|
||||
return { theme: prefersDark ? "dark" : "light" };
|
||||
}
|
||||
|
||||
return { theme: winCtx.color_scheme ?? fallback };
|
||||
return { theme };
|
||||
}
|
||||
|
||||
@@ -72,8 +72,9 @@ export const FieldLabel: React.FC<React.ComponentProps<"label"> & { field: Field
|
||||
}) => {
|
||||
const desc = field.getDescription();
|
||||
return (
|
||||
<Label {...props} title={desc} className="flex flex-row gap-2 items-center">
|
||||
<Label {...props} title={desc} className="flex flex-row gap-1 items-center">
|
||||
{field.getLabel()}
|
||||
{field.isRequired() && <span className="font-medium opacity-30">*</span>}
|
||||
{desc && <TbInfoCircle className="opacity-50" />}
|
||||
</Label>
|
||||
);
|
||||
@@ -189,7 +190,7 @@ export const Switch = forwardRef<
|
||||
>(({ type, required, ...props }, ref) => {
|
||||
return (
|
||||
<RadixSwitch.Root
|
||||
className="relative h-7 w-12 p-[2px] cursor-pointer rounded-full bg-muted border border-primary/10 outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
|
||||
className="relative h-7 w-12 cursor-pointer rounded-full bg-muted border-2 border-transparent outline-none data-[state=checked]:bg-primary/75 appearance-none transition-colors hover:bg-muted/80"
|
||||
onCheckedChange={(bool) => {
|
||||
props.onChange?.({ target: { value: bool } });
|
||||
}}
|
||||
@@ -203,7 +204,7 @@ export const Switch = forwardRef<
|
||||
}
|
||||
ref={ref}
|
||||
>
|
||||
<RadixSwitch.Thumb className="block h-full aspect-square translate-x-0 rounded-full bg-background transition-transform duration-100 will-change-transform border border-muted data-[state=checked]:translate-x-[17px]" />
|
||||
<RadixSwitch.Thumb className="absolute top-0 left-0 h-full aspect-square rounded-full bg-background transition-[left,right] duration-100 border border-muted data-[state=checked]:left-[calc(100%-1.5rem)]" />
|
||||
</RadixSwitch.Root>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -225,12 +225,12 @@ export function FormContextOverride({
|
||||
// especially useful for AnyOf, since it doesn't need to fully validate (e.g. pattern)
|
||||
if (prefix) {
|
||||
additional.root = prefix;
|
||||
additional.setValue = (pointer: string, value: any) => {
|
||||
ctx.setValue(prefixPointer(pointer, prefix), value);
|
||||
};
|
||||
additional.deleteValue = (pointer: string) => {
|
||||
ctx.deleteValue(prefixPointer(pointer, prefix));
|
||||
/*additional.setValue = (path: string, value: any) => {
|
||||
ctx.setValue(prefixPath(path, prefix), value);
|
||||
};
|
||||
additional.deleteValue = (path: string) => {
|
||||
ctx.deleteValue(prefixPath(path, prefix));
|
||||
};*/
|
||||
}
|
||||
|
||||
const context = {
|
||||
|
||||
@@ -58,6 +58,15 @@ export type DropzoneProps = {
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
};
|
||||
|
||||
function handleUploadError(e: unknown) {
|
||||
if (e && e instanceof XMLHttpRequest) {
|
||||
const res = JSON.parse(e.responseText) as any;
|
||||
alert(`Upload failed with code ${e.status}: ${res.error}`);
|
||||
} else {
|
||||
alert("Upload failed");
|
||||
}
|
||||
}
|
||||
|
||||
export function Dropzone({
|
||||
getUploadInfo,
|
||||
handleDelete,
|
||||
@@ -164,7 +173,11 @@ export function Dropzone({
|
||||
return;
|
||||
} else {
|
||||
for (const file of pendingFiles) {
|
||||
await uploadFileProgress(file);
|
||||
try {
|
||||
await uploadFileProgress(file);
|
||||
} catch (e) {
|
||||
handleUploadError(e);
|
||||
}
|
||||
}
|
||||
setUploading(false);
|
||||
onUploaded?.(files);
|
||||
@@ -226,8 +239,6 @@ export function Dropzone({
|
||||
const uploadInfo = getUploadInfo(file.body);
|
||||
console.log("dropzone:uploadInfo", uploadInfo);
|
||||
const { url, headers, method = "POST" } = uploadInfo;
|
||||
const formData = new FormData();
|
||||
formData.append("file", file.body);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
console.log("xhr:url", url);
|
||||
@@ -260,7 +271,7 @@ export function Dropzone({
|
||||
|
||||
xhr.onload = () => {
|
||||
console.log("onload", file.path, xhr.status);
|
||||
if (xhr.status === 200) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
//setFileState(file.path, "uploaded", 1);
|
||||
console.log("Upload complete");
|
||||
|
||||
@@ -281,8 +292,8 @@ export function Dropzone({
|
||||
resolve();
|
||||
} else {
|
||||
setFileState(file.path, "failed", 1);
|
||||
console.error("Upload failed with status: ", xhr.status);
|
||||
reject();
|
||||
console.error("Upload failed with status: ", xhr.status, xhr.statusText);
|
||||
reject(xhr);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -295,7 +306,7 @@ export function Dropzone({
|
||||
};
|
||||
|
||||
xhr.setRequestHeader("Accept", "application/json");
|
||||
xhr.send(formData);
|
||||
xhr.send(file.body);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,6 +377,14 @@ const DropzoneInner = ({
|
||||
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
||||
);
|
||||
|
||||
async function uploadHandler(file: FileState) {
|
||||
try {
|
||||
return await uploadFile(file);
|
||||
} catch (e) {
|
||||
handleUploadError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
@@ -385,7 +404,7 @@ const DropzoneInner = ({
|
||||
<Preview
|
||||
key={file.path}
|
||||
file={file}
|
||||
handleUpload={uploadFile}
|
||||
handleUpload={uploadHandler}
|
||||
handleDelete={deleteFile}
|
||||
/>
|
||||
))}
|
||||
@@ -452,6 +471,7 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-[49%] md:w-60 flex flex-col border border-muted relative",
|
||||
file.state === "failed" && "border-red-500 bg-red-200/20",
|
||||
file.state === "deleting" && "opacity-70"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
@tailwind utilities;
|
||||
|
||||
#bknd-admin.dark,
|
||||
.dark .bknd-admin {
|
||||
.dark .bknd-admin,
|
||||
.bknd-admin.dark {
|
||||
--color-primary: 250 250 250; /* zinc-50 */
|
||||
--color-background: 30 31 34;
|
||||
--color-muted: 47 47 52;
|
||||
|
||||
@@ -22,11 +22,7 @@ declare module "@mantine/modals" {
|
||||
}
|
||||
|
||||
export function BkndModalsProvider({ children }) {
|
||||
return (
|
||||
<ModalsProvider modals={modals} modalProps={{ className: "bknd-admin" }}>
|
||||
{children}
|
||||
</ModalsProvider>
|
||||
);
|
||||
return <ModalsProvider modals={modals}>{children}</ModalsProvider>;
|
||||
}
|
||||
|
||||
function open<Modal extends keyof typeof modals>(
|
||||
|
||||
@@ -25,7 +25,7 @@ export const Check = () => {
|
||||
);
|
||||
};
|
||||
|
||||
type TableProps = {
|
||||
export type EntityTableProps = {
|
||||
data: EntityData[];
|
||||
entity: Entity;
|
||||
select?: string[];
|
||||
@@ -44,7 +44,7 @@ type TableProps = {
|
||||
};
|
||||
};
|
||||
|
||||
export const EntityTable: React.FC<TableProps> = ({
|
||||
export const EntityTable: React.FC<EntityTableProps> = ({
|
||||
data = [],
|
||||
entity,
|
||||
select,
|
||||
@@ -184,7 +184,7 @@ const SortIndicator = ({
|
||||
sort,
|
||||
field
|
||||
}: {
|
||||
sort: Pick<TableProps, "sort">["sort"];
|
||||
sort: Pick<EntityTableProps, "sort">["sort"];
|
||||
field: string;
|
||||
}) => {
|
||||
if (!sort || sort.by !== field) return <TbSelector size={18} className="mt-[1px]" />;
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Popover } from "ui/components/overlay/Popover";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { routes } from "ui/lib/routes";
|
||||
import { useLocation } from "wouter";
|
||||
import { EntityTable } from "../EntityTable";
|
||||
import { EntityTable, type EntityTableProps } from "../EntityTable";
|
||||
import type { ResponseObject } from "modules/ModuleApi";
|
||||
|
||||
// @todo: allow clear if not required
|
||||
export function EntityRelationalFormField({
|
||||
@@ -20,7 +21,7 @@ export function EntityRelationalFormField({
|
||||
field,
|
||||
data,
|
||||
disabled,
|
||||
tabIndex
|
||||
tabIndex,
|
||||
}: {
|
||||
fieldApi: FieldApi<any, any>;
|
||||
field: RelationField;
|
||||
@@ -30,12 +31,18 @@ export function EntityRelationalFormField({
|
||||
}) {
|
||||
const { app } = useBknd();
|
||||
const entity = app.entity(field.target())!;
|
||||
const [query, setQuery] = useState<any>({ limit: 10, page: 1, perPage: 10 });
|
||||
const [query, setQuery] = useState<any>({
|
||||
limit: 10,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
select: entity.getSelect(undefined, "table"),
|
||||
});
|
||||
const [, navigate] = useLocation();
|
||||
const ref = useRef<any>(null);
|
||||
const $q = useEntityQuery(field.target(), undefined, {
|
||||
select: query.select,
|
||||
limit: query.limit,
|
||||
offset: (query.page - 1) * query.limit
|
||||
offset: (query.page - 1) * query.limit,
|
||||
});
|
||||
const [_value, _setValue] = useState<{ id: number | undefined; [key: string]: any }>();
|
||||
|
||||
@@ -120,8 +127,8 @@ export function EntityRelationalFormField({
|
||||
"Enter",
|
||||
() => {
|
||||
ref.current?.click();
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
])}
|
||||
>
|
||||
{_value ? (
|
||||
@@ -179,31 +186,37 @@ export function EntityRelationalFormField({
|
||||
);
|
||||
}
|
||||
|
||||
const PopoverTable = ({ container, entity, query, toggle, onClickRow, onClickPage }) => {
|
||||
type PropoverTableProps = Omit<EntityTableProps, "data"> & {
|
||||
container: ResponseObject;
|
||||
query: any;
|
||||
toggle: () => void;
|
||||
}
|
||||
const PopoverTable = ({ container, entity, query, toggle, onClickRow, onClickPage }: PropoverTableProps) => {
|
||||
function handleNext() {
|
||||
if (query.limit * query.page < container.meta?.count) {
|
||||
onClickPage(query.page + 1);
|
||||
onClickPage?.(query.page + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrev() {
|
||||
if (query.page > 1) {
|
||||
onClickPage(query.page - 1);
|
||||
onClickPage?.(query.page - 1);
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys([
|
||||
["ArrowRight", handleNext],
|
||||
["ArrowLeft", handlePrev],
|
||||
["Escape", toggle]
|
||||
["Escape", toggle],
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EntityTable
|
||||
classNames={{ value: "line-clamp-1 truncate max-w-52 text-nowrap" }}
|
||||
data={container.data ?? []}
|
||||
data={container ?? []}
|
||||
entity={entity}
|
||||
select={query.select}
|
||||
total={container.meta?.count}
|
||||
page={query.page}
|
||||
onClickRow={onClickRow}
|
||||
|
||||
@@ -111,10 +111,7 @@ CreateModal.defaultTitle = undefined;
|
||||
CreateModal.modalProps = {
|
||||
withCloseButton: false,
|
||||
size: "xl",
|
||||
padding: 0,
|
||||
classNames: {
|
||||
root: "bknd-admin"
|
||||
}
|
||||
padding: 0
|
||||
} satisfies Partial<ModalProps>;
|
||||
|
||||
export { ModalBody, ModalFooter, ModalTitle, useStepContext, relationsSchema };
|
||||
|
||||
@@ -262,18 +262,18 @@ function EntityDetailInner({
|
||||
navigate(routes.data.entity.edit(other.entity.name, row.id));
|
||||
}
|
||||
|
||||
function handleClickNew() {
|
||||
try {
|
||||
let handleClickNew: any;
|
||||
try {
|
||||
if (other.entity.type !== "system") {
|
||||
const ref = relation.getReferenceQuery(other.entity, id, other.reference);
|
||||
|
||||
navigate(routes.data.entity.create(other.entity.name), {
|
||||
query: ref.where
|
||||
});
|
||||
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
|
||||
} catch (e) {
|
||||
console.error("handleClickNew", e);
|
||||
handleClickNew = () => {
|
||||
navigate(routes.data.entity.create(other.entity.name), {
|
||||
query: ref.where
|
||||
});
|
||||
//navigate(routes.data.entity.create(other.entity.name) + `?${query}`);
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (!$q.data) {
|
||||
return null;
|
||||
|
||||
@@ -18,7 +18,7 @@ export function DataEntityCreate({ params }) {
|
||||
const entity = $data.entity(params.entity as string);
|
||||
if (!entity) {
|
||||
return <Message.NotFound description={`Entity "${params.entity}" doesn't exist.`} />;
|
||||
} else if (entity.type !== "regular") {
|
||||
} else if (entity.type === "system") {
|
||||
return <Message.NotAllowed description={`Entity "${params.entity}" cannot be created.`} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ function EntityCreateButton({ entity }: { entity: Entity }) {
|
||||
|
||||
const [navigate] = useNavigate();
|
||||
if (!entity) return null;
|
||||
if (entity.type !== "regular") {
|
||||
if (entity.type === "system") {
|
||||
const system = {
|
||||
users: b.app.config.auth.entity_name,
|
||||
media: b.app.config.media.entity_name
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user