diff --git a/.gitignore b/.gitignore index fe4c90f..3712ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ packages/media/.env .history **/*/.db/* **/*/.configs/* +**/*/.template/* **/*/*.db **/*/*.db-shm **/*/*.db-wal diff --git a/README.md b/README.md index e2add07..fb5e81f 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,10 @@ bknd simplifies app development by providing a fully functional backend for data > and therefore full backward compatibility is not guaranteed before reaching v1.0.0. ## Size -![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/index.js?compression=gzip&label=bknd) -![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/ui/client/index.js?compression=gzip&label=bknd/client) -![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) -![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/ui/index.js?compression=gzip&label=bknd/ui) +![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/index.js?compression=gzip&label=bknd) +![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/ui/client/index.js?compression=gzip&label=bknd/client) +![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) +![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/ui/index.js?compression=gzip&label=bknd/ui) The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets. diff --git a/app/__test__/_assets/.gitignore b/app/__test__/_assets/.gitignore new file mode 100644 index 0000000..e540f5b --- /dev/null +++ b/app/__test__/_assets/.gitignore @@ -0,0 +1 @@ +tmp/* \ No newline at end of file diff --git a/app/__test__/_assets/image.png b/app/__test__/_assets/image.png new file mode 100644 index 0000000..2ebf8fc Binary files /dev/null and b/app/__test__/_assets/image.png differ diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index dbbe35d..c3c997a 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -17,11 +17,11 @@ describe("DataApi", () => { const get = api.readMany("a".repeat(300), { select: ["id", "name"] }); expect(get.request.method).toBe("GET"); - expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`); + expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`); const post = api.readMany("a".repeat(1000), { select: ["id", "name"] }); expect(post.request.method).toBe("POST"); - expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`); + expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`); }); it("returns result", async () => { @@ -39,7 +39,7 @@ describe("DataApi", () => { const app = controller.getController(); { - const res = (await app.request("/posts")) as Response; + const res = (await app.request("/entity/posts")) as Response; const { data } = await res.json(); expect(data.length).toEqual(3); } diff --git a/app/__test__/api/MediaApi.spec.ts b/app/__test__/api/MediaApi.spec.ts new file mode 100644 index 0000000..27e86da --- /dev/null +++ b/app/__test__/api/MediaApi.spec.ts @@ -0,0 +1,146 @@ +/// +import { describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils"; +import { MediaApi } from "../../src/media/api/MediaApi"; +import { assetsPath, assetsTmpPath } from "../helper"; + +const mockedBackend = new Hono() + .basePath("/api/media") + .post("/upload/:name", async (c) => { + const { name } = c.req.param(); + const body = await getFileFromContext(c); + return c.json({ name, is_file: isFile(body), size: body.size }); + }) + .get("/file/:name", async (c) => { + const { name } = c.req.param(); + const file = Bun.file(`${assetsPath}/${name}`); + return new Response(file, { + headers: { + "Content-Type": file.type, + "Content-Length": file.size.toString() + } + }); + }); + +describe("MediaApi", () => { + it("should give correct file upload url", () => { + const host = "http://localhost"; + const basepath = "/api/media"; + // @ts-ignore tests + const api = new MediaApi({ + host, + basepath + }); + expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`); + }); + + it("should have correct upload headers", () => { + // @ts-ignore tests + const api = new MediaApi({ + token: "token" + }); + expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); + }); + + it("should get file: native", async () => { + const name = "image.png"; + const path = `${assetsTmpPath}/${name}`; + const res = await mockedBackend.request("/api/media/file/" + name); + await Bun.write(path, res); + + const file = await Bun.file(path); + expect(file.size).toBeGreaterThan(0); + expect(file.type).toBe("image/png"); + await file.delete(); + }); + + it("download", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + + const name = "image.png"; + const file = await api.download(name); + expect(isFile(file)).toBe(true); + expect(file.size).toBeGreaterThan(0); + expect(file.type).toBe("image/png"); + expect(file.name).toContain(name); + }); + + it("getFile", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + + const name = "image.png"; + const res = await api.getFile(name); + expect(res.ok).toBe(true); + // make sure it's a normal api request as usual + expect(res.res.ok).toBe(true); + expect(isReadableStream(res)).toBe(true); + expect(isReadableStream(res.body)).toBe(true); + expect(isReadableStream(res.res.body)).toBe(true); + + const blob = await res.res.blob(); + expect(isFile(blob)).toBe(true); + expect(blob.size).toBeGreaterThan(0); + expect(blob.type).toBe("image/png"); + expect(blob.name).toContain(name); + }); + + it("getFileStream", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + + const name = "image.png"; + const res = await api.getFileStream(name); + expect(isReadableStream(res)).toBe(true); + + const blob = await new Response(res).blob(); + expect(isFile(blob)).toBe(true); + expect(blob.size).toBeGreaterThan(0); + expect(blob.type).toBe("image/png"); + expect(blob.name).toContain(name); + }); + + it("should upload file in various ways", async () => { + // @ts-ignore tests + const api = new MediaApi({}, mockedBackend.request); + const file = Bun.file(`${assetsPath}/image.png`); + + async function matches(req: Promise, filename: string) { + const res: any = await req; + expect(res.name).toBe(filename); + expect(res.is_file).toBe(true); + expect(res.size).toBe(file.size); + } + + const url = "http://localhost/api/media/file/image.png"; + + // upload bun file + await matches(api.upload(file as any, { filename: "bunfile.png" }), "bunfile.png"); + + // upload via request + await matches(api.upload(new Request(url), { filename: "request.png" }), "request.png"); + + // upload via url + await matches(api.upload(url, { filename: "url.png" }), "url.png"); + + // upload via response + { + const response = await mockedBackend.request(url); + await matches(api.upload(response, { filename: "response.png" }), "response.png"); + } + + // upload via readable from bun + await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png"); + + // upload via readable from response + { + const response = (await mockedBackend.request(url)) as Response; + await matches( + await api.upload(response.body!, { filename: "readable.png" }), + "readable.png" + ); + } + }); +}); diff --git a/app/__test__/app/repro.spec.ts b/app/__test__/app/repro.spec.ts index 0d025b9..c76d55f 100644 --- a/app/__test__/app/repro.spec.ts +++ b/app/__test__/app/repro.spec.ts @@ -70,4 +70,34 @@ describe("repros", async () => { expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); }); + + test.only("verify inversedBy", async () => { + const schema = proto.em( + { + products: proto.entity("products", { + title: proto.text() + }), + product_likes: proto.entity("product_likes", { + created_at: proto.date() + }), + users: proto.entity("users", {}) + }, + (fns, schema) => { + fns.relation(schema.product_likes).manyToOne(schema.products, { inversedBy: "likes" }); + fns.relation(schema.product_likes).manyToOne(schema.users); + } + ); + const app = createApp({ initialConfig: { data: schema.toJSON() } }); + await app.build(); + + const info = (await (await app.server.request("/api/data/info/products")).json()) as any; + + expect(info.fields).toEqual(["id", "title"]); + expect(info.relations.listable).toEqual([ + { + entity: "product_likes", + ref: "likes" + } + ]); + }); }); diff --git a/app/__test__/auth/authorize/authorize.spec.ts b/app/__test__/auth/authorize/authorize.spec.ts index b6318d6..4ca0ce1 100644 --- a/app/__test__/auth/authorize/authorize.spec.ts +++ b/app/__test__/auth/authorize/authorize.spec.ts @@ -16,10 +16,8 @@ describe("authorize", () => { role: "admin" }; - guard.setUserContext(user); - - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted("read", user)).toBe(true); + expect(guard.granted("write", user)).toBe(true); expect(() => guard.granted("something")).toThrow(); }); @@ -46,10 +44,8 @@ describe("authorize", () => { role: "admin" }; - guard.setUserContext(user); - - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted("read", user)).toBe(true); + expect(guard.granted("write", user)).toBe(true); }); test("guard implicit allow", async () => { @@ -66,12 +62,12 @@ describe("authorize", () => { } }); - guard.setUserContext({ + const user = { role: "admin" - }); + }; - expect(guard.granted("read")).toBe(true); - expect(guard.granted("write")).toBe(true); + expect(guard.granted("read", user)).toBe(true); + expect(guard.granted("write", user)).toBe(true); }); test("guard with guest role implicit allow", async () => { diff --git a/app/__test__/core/utils.spec.ts b/app/__test__/core/utils.spec.ts index b484be8..3de8a48 100644 --- a/app/__test__/core/utils.spec.ts +++ b/app/__test__/core/utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { Perf } from "../../src/core/utils"; +import { Perf, isBlob, ucFirst } from "../../src/core/utils"; import * as utils from "../../src/core/utils"; async function wait(ms: number) { @@ -75,6 +75,57 @@ describe("Core Utils", async () => { const result3 = utils.encodeSearch(obj3, { encode: true }); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); }); + + describe("guards", () => { + const types = { + blob: new Blob(), + file: new File([""], "file.txt"), + stream: new ReadableStream(), + arrayBuffer: new ArrayBuffer(10), + arrayBufferView: new Uint8Array(new ArrayBuffer(10)) + }; + + const fns = [ + [utils.isReadableStream, "stream"], + [utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]], + [utils.isArrayBuffer, "arrayBuffer"], + [utils.isArrayBufferView, "arrayBufferView"] + ] as const; + + const additional = [0, 0.0, "", null, undefined, {}, []]; + + for (const [fn, type, _to_test] of fns) { + test(`is${ucFirst(type)}`, () => { + const to_test = _to_test ?? (Object.keys(types) as string[]); + for (const key of to_test) { + const value = types[key as keyof typeof types]; + const result = fn(value); + expect(result).toBe(key === type); + } + + for (const value of additional) { + const result = fn(value); + expect(result).toBe(false); + } + }); + } + }); + + test("getContentName", () => { + const name = "test.json"; + const text = "attachment; filename=" + name; + const headers = new Headers({ + "Content-Disposition": text + }); + const request = new Request("http://example.com", { + headers + }); + + expect(utils.getContentName(text)).toBe(name); + expect(utils.getContentName(headers)).toBe(name); + expect(utils.getContentName(request)).toBe(name); + }); }); describe("perf", async () => { @@ -134,8 +185,8 @@ describe("Core Utils", async () => { [true, true, true], [true, false, false], [false, false, true], - [1, NaN, false], - [NaN, NaN, true], + [1, Number.NaN, false], + [Number.NaN, Number.NaN, true], [null, null, true], [null, undefined, false], [undefined, undefined, true], diff --git a/app/__test__/data/DataController.spec.ts b/app/__test__/data/DataController.spec.ts index 42ded5f..cec2022 100644 --- a/app/__test__/data/DataController.spec.ts +++ b/app/__test__/data/DataController.spec.ts @@ -116,7 +116,7 @@ describe("[data] DataController", async () => { //console.log("app.routes", app.routes); // create users for await (const _user of fixtures.users) { - const res = await app.request("/users", { + const res = await app.request("/entity/users", { method: "POST", body: JSON.stringify(_user) }); @@ -131,7 +131,7 @@ describe("[data] DataController", async () => { // create posts for await (const _post of fixtures.posts) { - const res = await app.request("/posts", { + const res = await app.request("/entity/posts", { method: "POST", body: JSON.stringify(_post) }); @@ -145,7 +145,7 @@ describe("[data] DataController", async () => { }); test("/:entity (read many)", async () => { - const res = await app.request("/users"); + const res = await app.request("/entity/users"); const data = (await res.json()) as RepositoryResponse; expect(data.meta.total).toBe(3); @@ -156,7 +156,7 @@ describe("[data] DataController", async () => { }); test("/:entity/query (func query)", async () => { - const res = await app.request("/users/query", { + const res = await app.request("/entity/users/query", { method: "POST", headers: { "Content-Type": "application/json" @@ -175,7 +175,7 @@ describe("[data] DataController", async () => { }); test("/:entity (read many, paginated)", async () => { - const res = await app.request("/users?limit=1&offset=2"); + const res = await app.request("/entity/users?limit=1&offset=2"); const data = (await res.json()) as RepositoryResponse; expect(data.meta.total).toBe(3); @@ -186,7 +186,7 @@ describe("[data] DataController", async () => { }); test("/:entity/:id (read one)", async () => { - const res = await app.request("/users/3"); + const res = await app.request("/entity/users/3"); const data = (await res.json()) as RepositoryResponse; console.log("data", data); @@ -197,7 +197,7 @@ describe("[data] DataController", async () => { }); test("/:entity (update one)", async () => { - const res = await app.request("/users/3", { + const res = await app.request("/entity/users/3", { method: "PATCH", body: JSON.stringify({ name: "new name" }) }); @@ -208,7 +208,7 @@ describe("[data] DataController", async () => { }); test("/:entity/:id/:reference (read references)", async () => { - const res = await app.request("/users/1/posts"); + const res = await app.request("/entity/users/1/posts"); const data = (await res.json()) as RepositoryResponse; console.log("data", data); @@ -220,14 +220,14 @@ describe("[data] DataController", async () => { }); test("/:entity/:id (delete one)", async () => { - const res = await app.request("/posts/2", { + const res = await app.request("/entity/posts/2", { method: "DELETE" }); const { data } = (await res.json()) as RepositoryResponse; expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); // verify - const res2 = await app.request("/posts"); + const res2 = await app.request("/entity/posts"); const data2 = (await res2.json()) as RepositoryResponse; expect(data2.meta.total).toBe(1); }); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index f07cd34..8cafff2 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -68,3 +68,6 @@ export function schemaToEm(s: ReturnType, conn?: Connection): En const connection = conn ? conn : getDummyConnection().dummyConnection; return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices); } + +export const assetsPath = `${import.meta.dir}/_assets`; +export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`; diff --git a/app/__test__/integration/auth.integration.test.ts b/app/__test__/integration/auth.integration.test.ts index c103848..6f2466c 100644 --- a/app/__test__/integration/auth.integration.test.ts +++ b/app/__test__/integration/auth.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { App, createApp } from "../../src"; import type { AuthResponse } from "../../src/auth"; +import { auth } from "../../src/auth/middlewares"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog } from "../helper"; @@ -98,7 +99,7 @@ const fns = (app: App, mode?: Mode) = } return { - Authorization: `Bearer ${token}`, + Authorization: token ? `Bearer ${token}` : "", "Content-Type": "application/json", ...additional }; @@ -210,4 +211,36 @@ describe("integration auth", () => { expect(res.status).toBe(403); }); }); + + it("context is exclusive", async () => { + const app = createAuthApp(); + await app.build(); + const $fns = fns(app); + + app.server.get("/get", auth(), async (c) => { + return c.json({ + user: c.get("auth").user ?? null + }); + }); + app.server.get("/wait", auth(), async (c) => { + await new Promise((r) => setTimeout(r, 20)); + return c.json({ ok: true }); + }); + + const { data } = await $fns.login(configs.users.normal); + const me = await $fns.me(data.token); + expect(me.user.email).toBe(configs.users.normal.email); + + app.server.request("/wait", { + headers: { Authorization: `Bearer ${data.token}` } + }); + + { + await new Promise((r) => setTimeout(r, 10)); + const res = await app.server.request("/get"); + const data = await res.json(); + expect(data.user).toBe(null); + expect(await $fns.me()).toEqual({ user: null as any }); + } + }); }); diff --git a/app/__test__/integration/config.integration.test.ts b/app/__test__/integration/config.integration.test.ts new file mode 100644 index 0000000..6eef035 --- /dev/null +++ b/app/__test__/integration/config.integration.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { createApp } from "../../src"; +import { Api } from "../../src/Api"; + +describe("integration config", () => { + it("should create an entity", async () => { + const app = createApp(); + await app.build(); + const api = new Api({ + host: "http://localhost", + fetcher: app.server.request as typeof fetch + }); + + // create entity + await api.system.addConfig("data", "entities.posts", { + name: "posts", + config: { sort_field: "id", sort_dir: "asc" }, + fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } }, + type: "regular" + }); + + expect(app.em.entities.map((e) => e.name)).toContain("posts"); + }); +}); diff --git a/app/__test__/media/MediaController.spec.ts b/app/__test__/media/MediaController.spec.ts index 4816620..49b0900 100644 --- a/app/__test__/media/MediaController.spec.ts +++ b/app/__test__/media/MediaController.spec.ts @@ -1,56 +1,96 @@ -import { describe, test } from "bun:test"; -import { Hono } from "hono"; -import { Guard } from "../../src/auth"; -import { EventManager } from "../../src/core/events"; -import { EntityManager } from "../../src/data"; -import { AppMedia } from "../../src/media/AppMedia"; -import { MediaController } from "../../src/media/api/MediaController"; -import { getDummyConnection } from "../helper"; +/// -const { dummyConnection, afterAllCleanup } = getDummyConnection(); +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { createApp, registries } from "../../src"; +import { StorageLocalAdapter } from "../../src/adapter/node"; +import { mergeObject, randomString } from "../../src/core/utils"; +import type { TAppMediaConfig } from "../../src/media/media-schema"; +import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper"; -/** - * R2 - * value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, - * Node writefile - * data: string | NodeJS.ArrayBufferView | Iterable | AsyncIterable | Stream, - */ -const ALL_TESTS = !!process.env.ALL_TESTS; -describe.skipIf(ALL_TESTS)("MediaController", () => { - test("..", async () => { - const ctx: any = { - em: new EntityManager([], dummyConnection, []), - guard: new Guard(), - emgr: new EventManager(), - server: new Hono() - }; +beforeAll(() => { + registries.media.register("local", StorageLocalAdapter); +}); - const media = new AppMedia( - // @ts-ignore - { - enabled: true, - adapter: { - type: "s3", - config: { - access_key: process.env.R2_ACCESS_KEY as string, - secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string, - url: process.env.R2_URL as string +const path = `${assetsPath}/image.png`; + +async function makeApp(mediaOverride: Partial = {}) { + const app = createApp({ + initialConfig: { + media: mergeObject( + { + enabled: true, + adapter: { + type: "local", + config: { + path: assetsTmpPath + } } - } - }, - ctx - ); - await media.build(); - const app = new MediaController(media).getController(); + }, + mediaOverride + ) + } + }); - const file = Bun.file(`${import.meta.dir}/adapters/icon.png`); - console.log("file", file); - const form = new FormData(); - form.append("file", file); + await app.build(); + return app; +} - await app.request("/upload/test.png", { +function makeName(ext: string) { + return randomString(10) + "." + ext; +} + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("MediaController", () => { + test("accepts direct", async () => { + const app = await makeApp(); + + const file = Bun.file(path); + const name = makeName("png"); + const res = await app.server.request("/api/media/upload/" + name, { method: "POST", body: file }); + const result = (await res.json()) as any; + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); + + test("accepts form data", async () => { + const app = await makeApp(); + + const file = Bun.file(path); + const name = makeName("png"); + const form = new FormData(); + form.append("file", file); + + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: form + }); + const result = (await res.json()) as any; + expect(result.name).toBe(name); + + const destFile = Bun.file(assetsTmpPath + "/" + name); + expect(destFile.exists()).resolves.toBe(true); + await destFile.delete(); + }); + + test("limits body", async () => { + const app = await makeApp({ storage: { body_max_size: 1 } }); + + const file = await Bun.file(path); + const name = makeName("png"); + const res = await app.server.request("/api/media/upload/" + name, { + method: "POST", + body: file + }); + + expect(res.status).toBe(413); + expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false); }); }); diff --git a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts index a7c6d79..2240aeb 100644 --- a/app/__test__/media/adapters/StorageLocalAdapter.spec.ts +++ b/app/__test__/media/adapters/StorageLocalAdapter.spec.ts @@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => { test("puts an object", async () => { objects = (await adapter.listObjects()).length; - expect(await adapter.putObject(filename, file)).toBeString(); + expect(await adapter.putObject(filename, file as unknown as File)).toBeString(); }); test("lists objects", async () => { diff --git a/app/__test__/modules/ModuleManager.spec.ts b/app/__test__/modules/ModuleManager.spec.ts index e22afff..64e3cde 100644 --- a/app/__test__/modules/ModuleManager.spec.ts +++ b/app/__test__/modules/ModuleManager.spec.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from "bun:test"; -import { stripMark } from "../../src/core/utils"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils"; import { entity, text } from "../../src/data"; +import { Module } from "../../src/modules/Module"; import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager"; import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; import { getDummyConnection } from "../helper"; @@ -177,7 +178,7 @@ describe("ModuleManager", async () => { await mm.build(); const configs = stripMark(mm.configs()); - expect(mm.configs().server.admin.color_scheme).toBe("light"); + expect(mm.configs().server.admin.color_scheme).toBeUndefined(); expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow(); await mm.get("server").schema().patch("admin", { color_scheme: "dark" }); await mm.save(); @@ -252,4 +253,131 @@ describe("ModuleManager", async () => { }); // @todo: add tests for migrations (check "backup" and new version) + + describe("revert", async () => { + const failingModuleSchema = Type.Object({ + value: Type.Optional(Type.Number()) + }); + class FailingModule extends Module { + getSchema() { + return failingModuleSchema; + } + + override async build() { + //console.log("building FailingModule", this.config); + if (this.config.value < 0) { + throw new Error("value must be positive"); + } + this.setBuilt(); + } + } + class TestModuleManager extends ModuleManager { + constructor(...args: ConstructorParameters) { + super(...args); + const [, options] = args; + // @ts-ignore + const initial = options?.initial?.failing ?? {}; + this.modules["failing"] = new FailingModule(initial, this.ctx()); + this.modules["failing"].setListener(async (c) => { + // @ts-ignore + await this.onModuleConfigUpdated("failing", c); + }); + } + } + + beforeEach(() => disableConsoleLog(["log", "warn", "error"])); + afterEach(enableConsoleLog); + + test("it builds", async () => { + const { dummyConnection } = getDummyConnection(); + const mm = new TestModuleManager(dummyConnection); + expect(mm).toBeDefined(); + await mm.build(); + expect(mm.toJSON()).toBeDefined(); + }); + + test("it accepts config", async () => { + const { dummyConnection } = getDummyConnection(); + const mm = new TestModuleManager(dummyConnection, { + initial: { + // @ts-ignore + failing: { value: 2 } + } + }); + await mm.build(); + expect(mm.configs()["failing"].value).toBe(2); + }); + + test("it crashes on invalid", async () => { + const { dummyConnection } = getDummyConnection(); + const mm = new TestModuleManager(dummyConnection, { + initial: { + // @ts-ignore + failing: { value: -1 } + } + }); + expect(mm.build()).rejects.toThrow(/value must be positive/); + expect(mm.configs()["failing"].value).toBe(-1); + }); + + test("it correctly accepts valid", async () => { + const mockOnUpdated = mock(() => null); + const { dummyConnection } = getDummyConnection(); + const mm = new TestModuleManager(dummyConnection, { + onUpdated: async () => { + mockOnUpdated(); + } + }); + await mm.build(); + // @ts-ignore + const f = mm.mutateConfigSafe("failing"); + + expect(f.set({ value: 2 })).resolves.toBeDefined(); + expect(mockOnUpdated).toHaveBeenCalled(); + }); + + test("it reverts on safe mutate", async () => { + const mockOnUpdated = mock(() => null); + const { dummyConnection } = getDummyConnection(); + const mm = new TestModuleManager(dummyConnection, { + initial: { + // @ts-ignore + failing: { value: 1 } + }, + onUpdated: async () => { + mockOnUpdated(); + } + }); + await mm.build(); + expect(mm.configs()["failing"].value).toBe(1); + + // now safe mutate + // @ts-ignore + expect(mm.mutateConfigSafe("failing").set({ value: -2 })).rejects.toThrow( + /value must be positive/ + ); + expect(mm.configs()["failing"].value).toBe(1); + expect(mockOnUpdated).toHaveBeenCalled(); + }); + + test("it only accepts schema mutating methods", async () => { + const { dummyConnection } = getDummyConnection(); + const mm = new TestModuleManager(dummyConnection); + await mm.build(); + + // @ts-ignore + const f = mm.mutateConfigSafe("failing"); + + // @ts-expect-error + expect(() => f.has("value")).toThrow(); + // @ts-expect-error + expect(() => f.bypass()).toThrow(); + // @ts-expect-error + expect(() => f.clone()).toThrow(); + // @ts-expect-error + expect(() => f.get()).toThrow(); + // @ts-expect-error + expect(() => f.default()).toThrow(); + }); + }); }); diff --git a/app/build.ts b/app/build.ts index ebbf33a..ba14bc5 100644 --- a/app/build.ts +++ b/app/build.ts @@ -49,111 +49,117 @@ if (types && !watch) { /** * Building backend and general API */ -await tsup.build({ - minify, - sourcemap, - watch, - entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], - outDir: "dist", - external: ["bun:test", "@libsql/client"], - metafile: true, - platform: "browser", - format: ["esm"], - splitting: false, - treeshake: true, - loader: { - ".svg": "dataurl" - }, - onSuccess: async () => { - delayTypes(); - } -}); +async function buildApi() { + await tsup.build({ + minify, + sourcemap, + watch, + entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], + outDir: "dist", + external: ["bun:test", "@libsql/client", "bknd/client"], + metafile: true, + platform: "browser", + format: ["esm"], + splitting: false, + treeshake: true, + loader: { + ".svg": "dataurl" + }, + onSuccess: async () => { + delayTypes(); + } + }); +} /** * Building UI for direct imports */ -await tsup.build({ - minify, - sourcemap, - watch, - entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"], - outDir: "dist/ui", - external: [ - "bun:test", - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "use-sync-external-store", - /codemirror/, - "@xyflow/react", - "@mantine/core" - ], - metafile: true, - platform: "browser", - format: ["esm"], - splitting: false, - bundle: true, - treeshake: true, - loader: { - ".svg": "dataurl" - }, - esbuildOptions: (options) => { - options.logLevel = "silent"; - }, - onSuccess: async () => { - delayTypes(); - } -}); +async function buildUi() { + await tsup.build({ + minify, + sourcemap, + watch, + entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"], + outDir: "dist/ui", + external: [ + "bun:test", + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "use-sync-external-store", + /codemirror/, + "@xyflow/react", + "@mantine/core" + ], + metafile: true, + platform: "browser", + format: ["esm"], + splitting: false, + bundle: true, + treeshake: true, + loader: { + ".svg": "dataurl" + }, + esbuildOptions: (options) => { + options.logLevel = "silent"; + }, + onSuccess: async () => { + delayTypes(); + } + }); +} /** * Building UI Elements * - tailwind-merge is mocked, no exclude * - ui/client is external, and after built replaced with "bknd/client" */ -await tsup.build({ - minify, - sourcemap, - watch, - entry: ["src/ui/elements/index.ts"], - outDir: "dist/ui/elements", - external: [ - "ui/client", - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "use-sync-external-store" - ], - metafile: true, - platform: "browser", - format: ["esm"], - splitting: false, - bundle: true, - treeshake: true, - loader: { - ".svg": "dataurl" - }, - esbuildOptions: (options) => { - options.alias = { - // not important for elements, mock to reduce bundle - "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts" - }; - }, - onSuccess: async () => { - // manually replace ui/client with bknd/client - const path = "./dist/ui/elements/index.js"; - const bundle = await Bun.file(path).text(); - await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client")); +async function buildUiElements() { + await tsup.build({ + minify, + sourcemap, + watch, + entry: ["src/ui/elements/index.ts"], + outDir: "dist/ui/elements", + external: [ + "ui/client", + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "use-sync-external-store" + ], + metafile: true, + platform: "browser", + format: ["esm"], + splitting: false, + bundle: true, + treeshake: true, + loader: { + ".svg": "dataurl" + }, + esbuildOptions: (options) => { + options.alias = { + // not important for elements, mock to reduce bundle + "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts" + }; + }, + onSuccess: async () => { + // manually replace ui/client with bknd/client + const path = "./dist/ui/elements/index.js"; + const bundle = await Bun.file(path).text(); + await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client")); - delayTypes(); - } -}); + delayTypes(); + } + }); +} /** * Building adapters */ -function baseConfig(adapter: string): tsup.Options { +function baseConfig(adapter: string, overrides: Partial = {}): tsup.Options { return { minify, sourcemap, @@ -162,47 +168,61 @@ function baseConfig(adapter: string): tsup.Options { format: ["esm"], platform: "neutral", outDir: `dist/adapter/${adapter}`, + metafile: true, + splitting: false, + onSuccess: async () => { + delayTypes(); + }, + ...overrides, define: { - __isDev: "0" + __isDev: "0", + ...overrides.define }, external: [ /^cloudflare*/, /^@?(hono|libsql).*?/, /^(bknd|react|next|node).*?/, - /.*\.(html)$/ - ], - metafile: true, - splitting: false, - onSuccess: async () => { - delayTypes(); - } + /.*\.(html)$/, + ...(Array.isArray(overrides.external) ? overrides.external : []) + ] }; } -// base adapter handles -await tsup.build({ - ...baseConfig(""), - entry: ["src/adapter/index.ts"], - outDir: "dist/adapter" -}); +async function buildAdapters() { + // base adapter handles + await tsup.build({ + ...baseConfig(""), + entry: ["src/adapter/index.ts"], + outDir: "dist/adapter" + }); -// specific adatpers -await tsup.build(baseConfig("remix")); -await tsup.build(baseConfig("bun")); -await tsup.build(baseConfig("astro")); -await tsup.build(baseConfig("cloudflare")); + // specific adatpers + await tsup.build(baseConfig("remix")); + await tsup.build(baseConfig("bun")); + await tsup.build(baseConfig("astro")); + await tsup.build( + baseConfig("cloudflare", { + external: [/^kysely/] + }) + ); -await tsup.build({ - ...baseConfig("vite"), - platform: "node" -}); + await tsup.build({ + ...baseConfig("vite"), + platform: "node" + }); -await tsup.build({ - ...baseConfig("nextjs"), - platform: "node" -}); + await tsup.build({ + ...baseConfig("nextjs"), + platform: "node" + }); -await tsup.build({ - ...baseConfig("node"), - platform: "node" -}); + await tsup.build({ + ...baseConfig("node"), + platform: "node" + }); +} + +await buildApi(); +await buildUi(); +await buildUiElements(); +await buildAdapters(); diff --git a/app/package.json b/app/package.json index 71ca15f..791ea86 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.7.1", + "version": "0.8.0", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -49,17 +49,19 @@ "hono": "^4.6.12", "json-schema-form-react": "^0.0.2", "json-schema-library": "^10.0.0-rc7", + "json-schema-to-ts": "^3.1.1", "kysely": "^0.27.4", "liquidjs": "^10.15.0", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", + "picocolors": "^1.1.1", "radix-ui": "^1.1.2", - "json-schema-to-ts": "^3.1.1", "swr": "^2.2.5" }, "devDependencies": { "@aws-sdk/client-s3": "^3.613.0", + "@bluwy/giget-core": "^0.1.2", "@dagrejs/dagre": "^1.1.4", "@hono/typebox-validator": "^0.2.6", "@hono/vite-dev-server": "^0.17.0", @@ -74,8 +76,10 @@ "@vitejs/plugin-react": "^4.3.3", "autoprefixer": "^10.4.20", "clsx": "^2.1.1", + "dotenv": "^16.4.7", "esbuild-postcss": "^0.0.4", "jotai": "^2.10.1", + "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", "postcss": "^8.4.47", @@ -217,4 +221,4 @@ "bun", "node" ] -} +} \ No newline at end of file diff --git a/app/src/Api.ts b/app/src/Api.ts index f0946f0..2e35cee 100644 --- a/app/src/Api.ts +++ b/app/src/Api.ts @@ -22,6 +22,7 @@ export type ApiOptions = { key?: string; localStorage?: boolean; fetcher?: typeof fetch; + verbose?: boolean; verified?: boolean; } & ( | { @@ -196,7 +197,8 @@ export class Api { host: this.baseUrl, token: this.token, headers: this.options.headers, - token_transport: this.token_transport + token_transport: this.token_transport, + verbose: this.options.verbose }); } diff --git a/app/src/App.ts b/app/src/App.ts index d32d57c..06917d0 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -1,9 +1,12 @@ import type { CreateUserPayload } from "auth/AppAuth"; +import { Api, type ApiOptions } from "bknd/client"; +import { $console } from "core"; import { Event } from "core/events"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import type { Hono } from "hono"; import { type InitialModuleConfigs, + type ModuleBuildContext, ModuleManager, type ModuleManagerOptions, type Modules @@ -26,16 +29,22 @@ export class AppFirstBoot extends AppEvent { } export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; +export type AppOptions = { + plugins?: AppPlugin[]; + seed?: (ctx: ModuleBuildContext) => Promise; + manager?: Omit; +}; export type CreateAppConfig = { connection?: | Connection | { + // @deprecated type: "libsql"; config: LibSqlCredentials; - }; + } + | LibSqlCredentials; initialConfig?: InitialModuleConfigs; - plugins?: AppPlugin[]; - options?: Omit; + options?: AppOptions; }; export type AppConfig = InitialModuleConfigs; @@ -45,30 +54,34 @@ export class App { static readonly Events = AppEvents; adminController?: AdminController; private trigger_first_boot = false; + private plugins: AppPlugin[]; constructor( private connection: Connection, _initialConfig?: InitialModuleConfigs, - private plugins: AppPlugin[] = [], - moduleManagerOptions?: ModuleManagerOptions + private options?: AppOptions ) { + this.plugins = options?.plugins ?? []; this.modules = new ModuleManager(connection, { - ...moduleManagerOptions, + ...(options?.manager ?? {}), initial: _initialConfig, + seed: options?.seed, onUpdated: async (key, config) => { // if the EventManager was disabled, we assume we shouldn't // respond to events, such as "onUpdated". + // this is important if multiple changes are done, and then build() is called manually if (!this.emgr.enabled) { - console.warn("[APP] config updated, but event manager is disabled, skip."); + $console.warn("App config updated, but event manager is disabled, skip."); return; } - console.log("[APP] config updated", key); - await this.build({ sync: true, save: true }); + $console.log("App config updated", key); + // @todo: potentially double syncing + await this.build({ sync: true }); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); }, onFirstBoot: async () => { - console.log("[APP] first boot"); + $console.log("App first boot"); this.trigger_first_boot = true; }, onServerInit: async (server) => { @@ -85,16 +98,10 @@ export class App { return this.modules.ctx().emgr; } - async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) { + async build(options?: { sync?: boolean }) { + if (options?.sync) this.modules.ctx().flags.sync_required = true; await this.modules.build(); - if (options?.sync) { - const syncResult = await this.module.data.em - .schema() - .sync({ force: true, drop: options.drop }); - //console.log("syncing", syncResult); - } - const { guard, server } = this.modules.ctx(); // load system controller @@ -108,12 +115,6 @@ export class App { await this.emgr.emit(new AppBuiltEvent({ app: this })); - server.all("/api/*", async (c) => c.notFound()); - - if (options?.save) { - await this.modules.save(); - } - // first boot is set from ModuleManager when there wasn't a config table if (this.trigger_first_boot) { this.trigger_first_boot = false; @@ -122,7 +123,7 @@ export class App { } mutateConfig(module: Module) { - return this.modules.get(module).schema(); + return this.modules.mutateConfigSafe(module); } get server() { @@ -178,6 +179,15 @@ export class App { async createUser(p: CreateUserPayload) { return this.module.auth.createUser(p); } + + getApi(options: Request | ApiOptions = {}) { + const fetcher = this.server.request as typeof fetch; + if (options instanceof Request) { + return new Api({ request: options, headers: options.headers, fetcher }); + } + + return new Api({ host: "http://localhost", ...options, fetcher }); + } } export function createApp(config: CreateAppConfig = {}) { @@ -187,18 +197,25 @@ export function createApp(config: CreateAppConfig = {}) { if (Connection.isConnection(config.connection)) { connection = config.connection; } else if (typeof config.connection === "object") { - connection = new LibsqlConnection(config.connection.config); + if ("type" in config.connection) { + $console.warn( + "Using deprecated connection type 'libsql', use the 'config' object directly." + ); + connection = new LibsqlConnection(config.connection.config); + } else { + connection = new LibsqlConnection(config.connection); + } } else { connection = new LibsqlConnection({ url: ":memory:" }); - console.warn("[!] No connection provided, using in-memory database"); + $console.warn("No connection provided, using in-memory database"); } } catch (e) { - console.error("Could not create connection", e); + $console.error("Could not create connection", e); } if (!connection) { throw new Error("Invalid connection"); } - return new App(connection, config.initialConfig, config.plugins, config.options); + return new App(connection, config.initialConfig, config.options); } diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 05eec3c..851c54e 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -30,7 +30,6 @@ export function serve({ distPath, connection, initialConfig, - plugins, options, port = config.server.default_port, onBuilt, @@ -44,7 +43,6 @@ export function serve({ const app = await createApp({ connection, initialConfig, - plugins, options, onBuilt, buildConfig, diff --git a/app/src/adapter/cloudflare/D1Connection.ts b/app/src/adapter/cloudflare/D1Connection.ts new file mode 100644 index 0000000..810cca8 --- /dev/null +++ b/app/src/adapter/cloudflare/D1Connection.ts @@ -0,0 +1,63 @@ +/// + +import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data"; +import type { QB } from "data/connection/Connection"; +import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely"; +import { D1Dialect } from "kysely-d1"; + +export type D1ConnectionConfig = { + binding: D1Database; +}; + +class CustomD1Dialect extends D1Dialect { + override createIntrospector(db: Kysely): DatabaseIntrospector { + return new SqliteIntrospector(db, { + excludeTables: ["_cf_KV"] + }); + } +} + +export class D1Connection extends SqliteConnection { + constructor(private config: D1ConnectionConfig) { + const plugins = [new ParseJSONResultsPlugin()]; + + const kysely = new Kysely({ + dialect: new CustomD1Dialect({ database: config.binding }), + plugins + }); + super(kysely, {}, plugins); + } + + override supportsBatching(): boolean { + return true; + } + + override supportsIndices(): boolean { + return true; + } + + protected override async batch( + queries: [...Queries] + ): Promise<{ + [K in keyof Queries]: Awaited>; + }> { + const db = this.config.binding; + + const res = await db.batch( + queries.map((q) => { + const { sql, parameters } = q.compile(); + return db.prepare(sql).bind(...parameters); + }) + ); + + // let it run through plugins + const kyselyPlugins = new KyselyPluginRunner(this.plugins); + const data: any = []; + for (const r of res) { + const rows = await kyselyPlugins.transformResultRows(r.results); + data.push(rows); + } + + return data; + } +} diff --git a/app/src/media/storage/adapters/StorageR2Adapter.ts b/app/src/adapter/cloudflare/StorageR2Adapter.ts similarity index 72% rename from app/src/media/storage/adapters/StorageR2Adapter.ts rename to app/src/adapter/cloudflare/StorageR2Adapter.ts index 911fe37..aedeb10 100644 --- a/app/src/media/storage/adapters/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/StorageR2Adapter.ts @@ -1,6 +1,47 @@ -import { isDebug } from "core"; -import type { FileBody, StorageAdapter } from "../Storage"; -import { guessMimeType } from "../mime-types"; +import { registries } from "bknd"; +import { isDebug } from "bknd/core"; +import { StringEnum, Type } from "bknd/utils"; +import type { FileBody, StorageAdapter } from "media/storage/Storage"; +import { guess } from "media/storage/mime-types-tiny"; +import { getBindings } from "./bindings"; + +export function makeSchema(bindings: string[] = []) { + return Type.Object( + { + binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String()) + }, + { title: "R2", description: "Cloudflare R2 storage" } + ); +} + +export function registerMedia(env: Record) { + const r2_bindings = getBindings(env, "R2Bucket"); + + registries.media.register( + "r2", + class extends StorageR2Adapter { + constructor(private config: any) { + const binding = r2_bindings.find((b) => b.key === config.binding); + if (!binding) { + throw new Error(`No R2Bucket found with key ${config.binding}`); + } + + super(binding?.value); + } + + override getSchema() { + return makeSchema(r2_bindings.map((b) => b.key)); + } + + override toJSON() { + return { + ...super.toJSON(), + config: this.config + }; + } + } + ); +} /** * Adapter for R2 storage @@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter { } getSchema() { - return undefined; + return makeSchema(); } async putObject(key: string, body: FileBody) { @@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter { async getObject(key: string, headers: Headers): Promise { let object: R2ObjectBody | null; const responseHeaders = new Headers({ - "Accept-Ranges": "bytes" + "Accept-Ranges": "bytes", + "Content-Type": guess(key) }); //console.log("getObject:headers", headersToObject(headers)); @@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter { if (!metadata || Object.keys(metadata).length === 0) { // guessing is especially required for dev environment (miniflare) metadata = { - contentType: guessMimeType(object.key) + contentType: guess(object.key) }; } - //console.log("writeHttpMetadata", object.httpMetadata, metadata); for (const [key, value] of Object.entries(metadata)) { const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase(); @@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter { } return { - type: String(head.httpMetadata?.contentType ?? "application/octet-stream"), + type: String(head.httpMetadata?.contentType ?? guess(key)), size: head.size }; } diff --git a/app/src/adapter/cloudflare/bindings.ts b/app/src/adapter/cloudflare/bindings.ts new file mode 100644 index 0000000..491d0a8 --- /dev/null +++ b/app/src/adapter/cloudflare/bindings.ts @@ -0,0 +1,32 @@ +export type BindingTypeMap = { + D1Database: D1Database; + KVNamespace: KVNamespace; + DurableObjectNamespace: DurableObjectNamespace; + R2Bucket: R2Bucket; +}; + +export type GetBindingType = keyof BindingTypeMap; +export type BindingMap = { key: string; value: BindingTypeMap[T] }; + +export function getBindings(env: any, type: T): BindingMap[] { + const bindings: BindingMap[] = []; + for (const key in env) { + try { + if (env[key] && (env[key] as any).constructor.name === type) { + bindings.push({ + key, + value: env[key] as BindingTypeMap[T] + }); + } + } catch (e) {} + } + return bindings; +} + +export function getBinding(env: any, type: T): BindingMap { + const bindings = getBindings(env, type); + if (bindings.length === 0) { + throw new Error(`No ${type} found in bindings`); + } + return bindings[0] as BindingMap; +} diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 3a4044b..4486609 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,6 +1,11 @@ -import type { FrameworkBkndConfig } from "bknd/adapter"; +/// + +import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; +import { D1Connection } from "./D1Connection"; +import { registerMedia } from "./StorageR2Adapter"; +import { getBinding } from "./bindings"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import { getFresh, getWarm } from "./modes/fresh"; @@ -10,6 +15,7 @@ export type CloudflareBkndConfig = FrameworkBkndConfig> bindings?: (args: Context) => { kv?: KVNamespace; dobj?: DurableObjectNamespace; + db?: D1Database; }; static?: "kv" | "assets"; key?: string; @@ -26,7 +32,39 @@ export type Context = { ctx: ExecutionContext; }; -export function serve(config: CloudflareBkndConfig) { +let media_registered: boolean = false; +export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { + if (!media_registered) { + registerMedia(context.env as any); + media_registered = true; + } + + const appConfig = makeConfig(config, context); + const bindings = config.bindings?.(context); + if (!appConfig.connection) { + let db: D1Database | undefined; + if (bindings?.db) { + console.log("Using database from bindings"); + db = bindings.db; + } else if (Object.keys(context.env ?? {}).length > 0) { + const binding = getBinding(context.env, "D1Database"); + if (binding) { + console.log(`Using database from env "${binding.key}"`); + db = binding.value; + } + } + + if (db) { + appConfig.connection = new D1Connection({ binding: db }); + } else { + throw new Error("No database connection given"); + } + } + + return appConfig; +} + +export function serve(config: CloudflareBkndConfig = {}) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); @@ -61,8 +99,6 @@ export function serve(config: CloudflareBkndConfig) { } } - config.setAdminHtml = config.setAdminHtml && !!config.manifest; - const context = { request, env, ctx } as Context; const mode = config.mode ?? "warm"; diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index c2dd1c5..a10dc53 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,4 +1,18 @@ +import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; + export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh, getWarm } from "./modes/fresh"; export { getCached } from "./modes/cached"; export { DurableBkndApp, getDurable } from "./modes/durable"; +export { D1Connection, type D1ConnectionConfig }; +export { + getBinding, + getBindings, + type BindingTypeMap, + type GetBindingType, + type BindingMap +} from "./bindings"; + +export function d1(config: D1ConnectionConfig) { + return new D1Connection(config); +} diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index a367e5d..48f6926 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -1,6 +1,6 @@ import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context } from "../index"; +import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { const { kv } = config.bindings?.(env)!; @@ -16,7 +16,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg const app = await createRuntimeApp( { - ...config, + ...makeCfConfig(config, { env, ctx, ...args }), initialConfig, onBuilt: async (app) => { app.module.server.client.get("/__bknd/cache", async (c) => { diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index ef40987..7a2af67 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,11 +1,11 @@ import type { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context } from "../index"; +import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { return await createRuntimeApp( { - ...config, + ...makeCfConfig(config, ctx), adminOptions: config.html ? { html: config.html } : undefined }, ctx diff --git a/app/src/adapter/remix/remix.adapter.ts b/app/src/adapter/remix/remix.adapter.ts index 22470bb..38b81e8 100644 --- a/app/src/adapter/remix/remix.adapter.ts +++ b/app/src/adapter/remix/remix.adapter.ts @@ -1,6 +1,5 @@ import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; -import { Api } from "bknd/client"; export type RemixBkndConfig = FrameworkBkndConfig; @@ -9,29 +8,30 @@ type RemixContext = { }; let app: App; +let building: boolean = false; + +export async function getApp(config: RemixBkndConfig, args?: RemixContext) { + if (building) { + while (building) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + if (app) return app; + } + + building = true; + if (!app) { + app = await createFrameworkApp(config, args); + await app.build(); + } + building = false; + return app; +} + export function serve( config: RemixBkndConfig = {} ) { return async (args: Args) => { - if (!app) { - app = await createFrameworkApp(config, args); - } + app = await createFrameworkApp(config, args); return app.fetch(args.request); }; } - -export function withApi( - handler: (args: Args, api: Api) => Promise -) { - return async (args: Args) => { - if (!args.context.api) { - args.context.api = new Api({ - host: new URL(args.request.url).origin, - headers: args.request.headers - }); - await args.context.api.verifyAuth(); - } - - return handler(args, args.context.api); - }; -} diff --git a/app/src/auth/api/AuthController.ts b/app/src/auth/api/AuthController.ts index 2ba24a5..3687395 100644 --- a/app/src/auth/api/AuthController.ts +++ b/app/src/auth/api/AuthController.ts @@ -104,10 +104,9 @@ export class AuthController extends Controller { } hono.get("/me", auth(), async (c) => { - if (this.auth.authenticator.isUserLoggedIn()) { - const claims = this.auth.authenticator.getUser()!; + const claims = c.get("auth")?.user; + if (claims) { const { data: user } = await this.userRepo.findId(claims.id); - return c.json({ user }); } @@ -133,6 +132,6 @@ export class AuthController extends Controller { return c.json({ strategies, basepath }); }); - return hono; + return hono.all("*", (c) => c.notFound()); } } diff --git a/app/src/auth/authenticate/Authenticator.ts b/app/src/auth/authenticate/Authenticator.ts index 7853dcd..f869318 100644 --- a/app/src/auth/authenticate/Authenticator.ts +++ b/app/src/auth/authenticate/Authenticator.ts @@ -106,17 +106,15 @@ export type AuthUserResolver = ( identifier: string, profile: ProfileExchange ) => Promise; +type AuthClaims = SafeUser & { + iat: number; + iss?: string; + exp?: number; +}; export class Authenticator = Record> { private readonly strategies: Strategies; private readonly config: AuthConfig; - private _claims: - | undefined - | (SafeUser & { - iat: number; - iss?: string; - exp?: number; - }); private readonly userResolver: AuthUserResolver; constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { @@ -148,21 +146,6 @@ export class Authenticator = Record< return this.strategies; } - isUserLoggedIn(): boolean { - return this._claims !== undefined; - } - - getUser(): SafeUser | undefined { - if (!this._claims) return; - - const { iat, exp, iss, ...user } = this._claims; - return user; - } - - resetUser() { - this._claims = undefined; - } - strategy< StrategyName extends keyof Strategies, Strat extends Strategy = Strategies[StrategyName] @@ -206,7 +189,7 @@ export class Authenticator = Record< return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); } - async verify(jwt: string): Promise { + async verify(jwt: string): Promise { try { const payload = await verify( jwt, @@ -221,14 +204,10 @@ export class Authenticator = Record< } } - this._claims = payload as any; - return true; - } catch (e) { - this.resetUser(); - //console.error(e); - } + return payload as any; + } catch (e) {} - return false; + return; } private get cookieOptions(): CookieOptions { @@ -258,8 +237,8 @@ export class Authenticator = Record< } } - async requestCookieRefresh(c: Context) { - if (this.config.cookie.renew && this.isUserLoggedIn()) { + async requestCookieRefresh(c: Context) { + if (this.config.cookie.renew && c.get("auth")?.user) { const token = await this.getAuthCookie(c); if (token) { await this.setAuthCookie(c, token); @@ -276,13 +255,14 @@ export class Authenticator = Record< await deleteCookie(c, "auth", this.cookieOptions); } - async logout(c: Context) { + async logout(c: Context) { + c.set("auth", undefined); + const cookie = await this.getAuthCookie(c); if (cookie) { await this.deleteAuthCookie(c); await addFlashMessage(c, "Signed out", "info"); } - this.resetUser(); } // @todo: move this to a server helper @@ -353,8 +333,7 @@ export class Authenticator = Record< } if (token) { - await this.verify(token); - return this.getUser(); + return await this.verify(token); } return undefined; diff --git a/app/src/auth/authorize/Guard.ts b/app/src/auth/authorize/Guard.ts index 15e3af2..8fbeadd 100644 --- a/app/src/auth/authorize/Guard.ts +++ b/app/src/auth/authorize/Guard.ts @@ -1,21 +1,23 @@ import { Exception, Permission } from "core"; import { objectTransform } from "core/utils"; +import type { Context } from "hono"; +import type { ServerEnv } from "modules/Module"; import { Role } from "./Role"; export type GuardUserContext = { - role: string | null | undefined; + role?: string | null; [key: string]: any; }; export type GuardConfig = { enabled?: boolean; }; +export type GuardContext = Context | GuardUserContext; const debug = false; export class Guard { permissions: Permission[]; - user?: GuardUserContext; roles?: Role[]; config?: GuardConfig; @@ -89,24 +91,19 @@ export class Guard { return this; } - setUserContext(user: GuardUserContext | undefined) { - this.user = user; - return this; - } - - getUserRole(): Role | undefined { - if (this.user && typeof this.user.role === "string") { - const role = this.roles?.find((role) => role.name === this.user?.role); + getUserRole(user?: GuardUserContext): Role | undefined { + if (user && typeof user.role === "string") { + const role = this.roles?.find((role) => role.name === user?.role); if (role) { - debug && console.log("guard: role found", [this.user.role]); + debug && console.log("guard: role found", [user.role]); return role; } } debug && console.log("guard: role not found", { - user: this.user, - role: this.user?.role + user: user, + role: user?.role }); return this.getDefaultRole(); } @@ -119,9 +116,9 @@ export class Guard { return this.config?.enabled === true; } - hasPermission(permission: Permission): boolean; - hasPermission(name: string): boolean; - hasPermission(permissionOrName: Permission | string): boolean { + hasPermission(permission: Permission, user?: GuardUserContext): boolean; + hasPermission(name: string, user?: GuardUserContext): boolean; + hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean { if (!this.isEnabled()) { //console.log("guard not enabled, allowing"); return true; @@ -133,7 +130,7 @@ export class Guard { throw new Error(`Permission ${name} does not exist`); } - const role = this.getUserRole(); + const role = this.getUserRole(user); if (!role) { debug && console.log("guard: role not found, denying"); @@ -156,12 +153,13 @@ export class Guard { return !!rolePermission; } - granted(permission: Permission | string): boolean { - return this.hasPermission(permission as any); + granted(permission: Permission | string, c?: GuardContext): boolean { + const user = c && "get" in c ? c.get("auth")?.user : c; + return this.hasPermission(permission as any, user); } - throwUnlessGranted(permission: Permission | string) { - if (!this.granted(permission)) { + throwUnlessGranted(permission: Permission | string, c: GuardContext) { + if (!this.granted(permission, c)) { throw new Exception( `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, 403 diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index f36c94b..677478b 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) { } export function shouldSkip(c: Context, skip?: (string | RegExp)[]) { - if (c.get("auth_skip")) return true; + const authCtx = c.get("auth"); + if (!authCtx) { + throw new Error("auth ctx not found"); + } + + if (authCtx.skip) return true; const req = c.req.raw; if (!skip) return false; @@ -18,7 +23,7 @@ export function shouldSkip(c: Context, skip?: (string | RegExp)[]) { const path = getPath(req); const result = skip.some((s) => patternMatch(path, s)); - c.set("auth_skip", result); + authCtx.skip = result; return result; } @@ -26,29 +31,31 @@ export const auth = (options?: { skip?: (string | RegExp)[]; }) => createMiddleware(async (c, next) => { + if (!c.get("auth")) { + c.set("auth", { + registered: false, + resolved: false, + skip: false, + user: undefined + }); + } + const app = c.get("app"); - const guard = app?.modules.ctx().guard; + const authCtx = c.get("auth")!; const authenticator = app?.module.auth.authenticator; let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; // make sure to only register once - if (c.get("auth_registered")) { + if (authCtx.registered) { skipped = true; console.warn(`auth middleware already registered for ${getPath(c)}`); } else { - c.set("auth_registered", true); + authCtx.registered = true; - if (!skipped) { - const resolved = c.get("auth_resolved"); - if (!resolved) { - if (!app?.module.auth.enabled) { - guard?.setUserContext(undefined); - } else { - guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c)); - c.set("auth_resolved", true); - } - } + if (!skipped && !authCtx.resolved && app?.module.auth.enabled) { + authCtx.user = await authenticator?.resolveAuthFromRequest(c); + authCtx.resolved = true; } } @@ -60,9 +67,9 @@ export const auth = (options?: { } // release - guard?.setUserContext(undefined); - authenticator?.resetUser(); - c.set("auth_resolved", false); + authCtx.skip = false; + authCtx.resolved = false; + authCtx.user = undefined; }); export const permission = ( @@ -75,23 +82,26 @@ export const permission = ( // @ts-ignore createMiddleware(async (c, next) => { const app = c.get("app"); - //console.log("skip?", c.get("auth_skip")); + const authCtx = c.get("auth"); + if (!authCtx) { + throw new Error("auth ctx not found"); + } // in tests, app is not defined - if (!c.get("auth_registered") || !app) { + if (!authCtx.registered || !app) { const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; if (app?.module.auth.enabled) { throw new Error(msg); } else { console.warn(msg); } - } else if (!c.get("auth_skip")) { + } else if (!authCtx.skip) { const guard = app.modules.ctx().guard; const permissions = Array.isArray(permission) ? permission : [permission]; if (options?.onGranted || options?.onDenied) { let returned: undefined | void | Response; - if (permissions.every((p) => guard.granted(p))) { + if (permissions.every((p) => guard.granted(p, c))) { returned = await options?.onGranted?.(c); } else { returned = await options?.onDenied?.(c); @@ -100,7 +110,7 @@ export const permission = ( return returned; } } else { - permissions.some((p) => guard.throwUnlessGranted(p)); + permissions.some((p) => guard.throwUnlessGranted(p, c)); } } diff --git a/app/src/cli/commands/create/create.ts b/app/src/cli/commands/create/create.ts new file mode 100644 index 0000000..6d74385 --- /dev/null +++ b/app/src/cli/commands/create/create.ts @@ -0,0 +1,294 @@ +import fs from "node:fs"; +import { downloadTemplate } from "@bluwy/giget-core"; +import * as $p from "@clack/prompts"; +import type { CliCommand } from "cli/types"; +import { typewriter, wait } from "cli/utils/cli"; +import { execAsync, getVersion } from "cli/utils/sys"; +import { Option } from "commander"; +import { colorizeConsole } from "core"; +import color from "picocolors"; +import { overridePackageJson, updateBkndPackages } from "./npm"; +import { type Template, templates } from "./templates"; + +const config = { + types: { + runtime: "Runtime", + framework: "Framework" + }, + runtime: { + node: "Node.js", + bun: "Bun", + cloudflare: "Cloudflare" + }, + framework: { + nextjs: "Next.js", + remix: "Remix", + astro: "Astro" + } +} as const; + +export const create: CliCommand = (program) => { + program + .command("create") + .addOption(new Option("-i, --integration ", "integration to use")) + .addOption(new Option("-t, --template