diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a89a192..5b2869c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: "1.3.1" + bun-version: "1.3.3" - name: Install dependencies working-directory: ./app diff --git a/.gitignore b/.gitignore index d26f2de..55aaa73 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,8 @@ packages/media/.env .npmrc /.verdaccio .idea -.vscode +.vscode/* +!.vscode/settings.json .git_old docker/tmp .debug diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5c3e1c6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,19 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "biome.enabled": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + //"source.organizeImports.biome": "explicit", + "source.fixAll.biome": "explicit" + }, + "typescript.preferences.importModuleSpecifier": "non-relative", + "typescript.preferences.autoImportFileExcludePatterns": [ + "**/dist/**", + "**/node_modules/**/dist/**", + "**/node_modules/**/!(src|lib|esm)/**" // optional, stricter + ], + "typescript.preferences.includePackageJsonAutoImports": "on", + "typescript.tsserver.watchOptions": { + "excludeDirectories": ["**/dist", "**/node_modules/**/dist"] + } +} diff --git a/app/.env.example b/app/.env.example index a70d8e7..463c8a7 100644 --- a/app/.env.example +++ b/app/.env.example @@ -20,6 +20,7 @@ VITE_SHOW_ROUTES= # ===== Test Credentials ===== RESEND_API_KEY= +PLUNK_API_KEY= R2_TOKEN= R2_ACCESS_KEY= diff --git a/app/.vscode/settings.json b/app/.vscode/settings.json new file mode 100644 index 0000000..adf0255 --- /dev/null +++ b/app/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "typescript.preferences.includePackageJsonAutoImports": "off", + "typescript.suggest.autoImports": true, + "typescript.preferences.importModuleSpecifier": "relative", + "search.exclude": { + "**/dist/**": true, + "**/node_modules/**": true + }, + "files.exclude": { + "**/dist/**": true + } +} diff --git a/app/__test__/app/modes.test.ts b/app/__test__/app/modes.test.ts new file mode 100644 index 0000000..034cf92 --- /dev/null +++ b/app/__test__/app/modes.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { code, hybrid } from "modes"; + +describe("modes", () => { + describe("code", () => { + test("verify base configuration", async () => { + const c = code({}) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["options"]); + expect(config.options.mode).toEqual("code"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + + test("keeps overrides", async () => { + const c = code({ + connection: { + url: ":memory:", + }, + }) as any; + const config = await c.app?.({} as any); + expect(config.connection.url).toEqual(":memory:"); + }); + }); + + describe("hybrid", () => { + test("fails if no reader is provided", () => { + // @ts-ignore + expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/); + }); + test("verify base configuration", async () => { + const c = hybrid({ reader: async () => ({}) }) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]); + expect(config.options.mode).toEqual("db"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + }); +}); diff --git a/app/__test__/auth/authorize/http/DataController.test.ts b/app/__test__/auth/authorize/http/DataController.test.ts new file mode 100644 index 0000000..0a4fc52 --- /dev/null +++ b/app/__test__/auth/authorize/http/DataController.test.ts @@ -0,0 +1,40 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; +import { em, entity, text } from "data/prototype"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +const schema = em( + { + posts: entity("posts", { + title: text(), + content: text(), + }), + comments: entity("comments", { + content: text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(posts).manyToOne(comments); + }, +); + +describe("DataController (auth)", () => { + test("reading schema.json", async () => { + const { request } = await createAuthTestApp( + { + permission: ["system.access.api", "data.entity.read", "system.schema.read"], + request: new Request("http://localhost/api/data/schema.json"), + }, + { + config: { data: schema.toJSON() }, + }, + ); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/SystemController.spec.ts b/app/__test__/auth/authorize/http/SystemController.spec.ts deleted file mode 100644 index 40e6493..0000000 --- a/app/__test__/auth/authorize/http/SystemController.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from "bun:test"; -import { SystemController } from "modules/server/SystemController"; -import { createApp } from "core/test/utils"; -import type { CreateAppConfig } from "App"; -import { getPermissionRoutes } from "auth/middlewares/permission.middleware"; - -async function makeApp(config: Partial = {}) { - const app = createApp(config); - await app.build(); - return app; -} - -describe.skip("SystemController", () => { - it("...", async () => { - const app = await makeApp(); - const controller = new SystemController(app); - const hono = controller.getController(); - console.log(getPermissionRoutes(hono)); - }); -}); diff --git a/app/__test__/auth/authorize/http/SystemController.test.ts b/app/__test__/auth/authorize/http/SystemController.test.ts new file mode 100644 index 0000000..7974a0d --- /dev/null +++ b/app/__test__/auth/authorize/http/SystemController.test.ts @@ -0,0 +1,41 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { createAuthTestApp } from "./shared"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("SystemController (auth)", () => { + test("reading info", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.info"], + request: new Request("http://localhost/api/system/info"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("reading permissions", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.schema.read"], + request: new Request("http://localhost/api/system/permissions"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); + + test("access openapi", async () => { + const { request } = await createAuthTestApp({ + permission: ["system.access.api", "system.openapi"], + request: new Request("http://localhost/api/system/openapi.json"), + }); + expect((await request.guest()).status).toBe(403); + expect((await request.member()).status).toBe(403); + expect((await request.authorized()).status).toBe(200); + expect((await request.admin()).status).toBe(200); + }); +}); diff --git a/app/__test__/auth/authorize/http/shared.ts b/app/__test__/auth/authorize/http/shared.ts new file mode 100644 index 0000000..7ec79f9 --- /dev/null +++ b/app/__test__/auth/authorize/http/shared.ts @@ -0,0 +1,171 @@ +import { createApp } from "core/test/utils"; +import type { CreateAppConfig } from "App"; +import type { RoleSchema } from "auth/authorize/Role"; +import { isPlainObject } from "core/utils"; + +export type AuthTestConfig = { + guest?: RoleSchema; + member?: RoleSchema; + authorized?: RoleSchema; +}; + +export async function createAuthTestApp( + testConfig: { + permission: AuthTestConfig | string | string[]; + request: Request; + }, + config: Partial = {}, +) { + let member: RoleSchema | undefined; + let authorized: RoleSchema | undefined; + let guest: RoleSchema | undefined; + if (isPlainObject(testConfig.permission)) { + if (testConfig.permission.guest) + guest = { + ...testConfig.permission.guest, + is_default: true, + }; + if (testConfig.permission.member) member = testConfig.permission.member; + if (testConfig.permission.authorized) authorized = testConfig.permission.authorized; + } else { + member = { + permissions: [], + }; + authorized = { + permissions: Array.isArray(testConfig.permission) + ? testConfig.permission + : [testConfig.permission], + }; + guest = { + permissions: [], + is_default: true, + }; + } + + console.log("authorized", authorized); + + const app = createApp({ + ...config, + config: { + ...config.config, + auth: { + ...config.config?.auth, + enabled: true, + guard: { + enabled: true, + ...config.config?.auth?.guard, + }, + jwt: { + ...config.config?.auth?.jwt, + secret: "secret", + }, + roles: { + ...config.config?.auth?.roles, + guest, + member, + authorized, + admin: { + implicit_allow: true, + }, + }, + }, + }, + }); + await app.build(); + + const users = { + guest: null, + member: await app.createUser({ + email: "member@test.com", + password: "12345678", + role: "member", + }), + authorized: await app.createUser({ + email: "authorized@test.com", + password: "12345678", + role: "authorized", + }), + admin: await app.createUser({ + email: "admin@test.com", + password: "12345678", + role: "admin", + }), + } as const; + + const tokens = {} as Record; + for (const [key, user] of Object.entries(users)) { + if (user) { + tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user); + } + } + + async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) { + const headers = new Headers(init.headers ?? {}); + if (user in tokens) { + headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`); + } + const res = await app.server.request(input, { + ...init, + headers, + }); + + let data: any; + if (res.headers.get("Content-Type")?.startsWith("application/json")) { + data = await res.json(); + } else if (res.headers.get("Content-Type")?.startsWith("text/")) { + data = await res.text(); + } + + return { + status: res.status, + ok: res.ok, + headers: Object.fromEntries(res.headers.entries()), + data, + }; + } + + const requestFn = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async (input: string, init: RequestInit = {}) => { + return makeRequest(prop, input, init); + }; + }, + }, + ) as { + [K in keyof typeof users]: ( + input: string, + init?: RequestInit, + ) => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + const request = new Proxy( + {}, + { + get(_, prop: keyof typeof users) { + return async () => { + return makeRequest(prop, testConfig.request.url, { + headers: testConfig.request.headers, + method: testConfig.request.method, + body: testConfig.request.body, + }); + }; + }, + }, + ) as { + [K in keyof typeof users]: () => Promise<{ + status: number; + ok: boolean; + headers: Record; + data: any; + }>; + }; + + return { app, users, request, requestFn }; +} diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index d9b2dc2..efb987d 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -124,6 +124,81 @@ describe("[Repository]", async () => { .then((r) => [r.count, r.total]), ).resolves.toEqual([undefined, undefined]); }); + + test("auto join", async () => { + const schema = $em( + { + posts: $entity("posts", { + title: $text(), + content: $text(), + }), + comments: $entity("comments", { + content: $text(), + }), + another: $entity("another", { + title: $text(), + }), + }, + ({ relation }, { posts, comments }) => { + relation(comments).manyToOne(posts); + }, + ); + const em = schema.proto.withConnection(getDummyConnection().dummyConnection); + await em.schema().sync({ force: true }); + + await em.mutator("posts").insertOne({ title: "post1", content: "content1" }); + await em + .mutator("comments") + .insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any); + + const res = await em.repo("comments").findMany({ + where: { + "posts.title": "post1", + }, + }); + expect(res.data as any).toEqual([ + { + id: 1, + content: "comment1", + posts_id: 1, + }, + ]); + + { + // manual join should still work + const res = await em.repo("comments").findMany({ + join: ["posts"], + where: { + "posts.title": "post1", + }, + }); + expect(res.data as any).toEqual([ + { + id: 1, + content: "comment1", + posts_id: 1, + }, + ]); + } + + // inexistent should be detected and thrown + expect( + em.repo("comments").findMany({ + where: { + "random.title": "post1", + }, + }), + ).rejects.toThrow(/Invalid where field/); + + // existing alias, but not a relation should throw + expect( + em.repo("comments").findMany({ + where: { + "another.title": "post1", + }, + }), + ).rejects.toThrow(/Invalid where field/); + }); }); describe("[data] Repository (Events)", async () => { diff --git a/app/build.ts b/app/build.ts index 3de4e2a..599ce82 100644 --- a/app/build.ts +++ b/app/build.ts @@ -186,6 +186,8 @@ async function buildUiElements() { outDir: "dist/ui/elements", external: [ "ui/client", + "bknd", + /^bknd\/.*/, "react", "react-dom", "react/jsx-runtime", diff --git a/app/package.json b/app/package.json index 408413f..8e36a10 100644 --- a/app/package.json +++ b/app/package.json @@ -13,7 +13,7 @@ "bugs": { "url": "https://github.com/bknd-io/bknd/issues" }, - "packageManager": "bun@1.3.1", + "packageManager": "bun@1.3.3", "engines": { "node": ">=22.13" }, diff --git a/app/src/App.ts b/app/src/App.ts index 633b9fa..2a8d203 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -5,7 +5,6 @@ import type { em as prototypeEm } from "data/prototype"; import { Connection } from "data/connection/Connection"; import type { Hono } from "hono"; import { - type InitialModuleConfigs, type ModuleConfigs, type Modules, ModuleManager, @@ -381,8 +380,10 @@ export class App< if (results.length > 0) { for (const { name, result } of results) { if (result) { - $console.log(`[Plugin:${name}] schema`); ctx.helper.ensureSchema(result); + if (ctx.flags.sync_required) { + $console.log(`[Plugin:${name}] schema, sync required`); + } } } } diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 51c9be3..c5d640d 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -43,6 +43,7 @@ export function createHandler( export function serve( { + app, distPath, connection, config: _config, @@ -62,6 +63,7 @@ export function serve( port, fetch: createHandler( { + app, connection, config: _config, options, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index e263756..98df2b0 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -3,7 +3,7 @@ import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { MaybePromise } from "bknd"; +import type { App, MaybePromise } from "bknd"; import { $console } from "bknd/utils"; import { createRuntimeApp } from "bknd/adapter"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; @@ -55,8 +55,12 @@ export async function createApp( // compatiblity export const getFresh = createApp; +let app: App | undefined; export function serve( config: CloudflareBkndConfig = {}, + serveOptions?: (args: Env) => { + warm?: boolean; + }, ) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { @@ -92,8 +96,11 @@ export function serve( } } - const context = { request, env, ctx } as CloudflareContext; - const app = await createApp(config, context); + const { warm } = serveOptions?.(env) ?? {}; + if (!app || warm !== true) { + const context = { request, env, ctx } as CloudflareContext; + app = await createApp(config, context); + } return app.fetch(request, env, ctx); }, diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts index 9efd5c4..3476315 100644 --- a/app/src/adapter/cloudflare/proxy.ts +++ b/app/src/adapter/cloudflare/proxy.ts @@ -65,37 +65,31 @@ export function withPlatformProxy( } return { - ...config, - beforeBuild: async (app, registries) => { - if (!use_proxy) return; - const env = await getEnv(); - registerMedia(env, registries as any); - await config?.beforeBuild?.(app, registries); - }, - bindings: async (env) => { - return (await config?.bindings?.(await getEnv(env))) || {}; - }, // @ts-ignore app: async (_env) => { const env = await getEnv(_env); const binding = use_proxy ? getBinding(env, "D1Database") : undefined; + const appConfig = typeof config.app === "function" ? await config.app(env) : config; + const connection = + use_proxy && binding + ? d1Sqlite({ + binding: binding.value as any, + }) + : appConfig.connection; - if (config?.app === undefined && use_proxy && binding) { - return { - connection: d1Sqlite({ - binding: binding.value, - }), - }; - } else if (typeof config?.app === "function") { - const appConfig = await config?.app(env); - if (binding) { - appConfig.connection = d1Sqlite({ - binding: binding.value, - }) as any; - } - return appConfig; - } - return config?.app || {}; + return { + ...appConfig, + beforeBuild: async (app, registries) => { + if (!use_proxy) return; + const env = await getEnv(); + registerMedia(env, registries as any); + await config?.beforeBuild?.(app, registries); + }, + bindings: async (env) => { + return (await config?.bindings?.(await getEnv(env))) || {}; + }, + connection, + }; }, } satisfies CloudflareBkndConfig; } diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 79f4c97..6568d29 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -14,14 +14,15 @@ import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; export type BkndConfig = Merge< - CreateAppConfig & { - app?: - | Merge & Additional> - | ((args: Args) => MaybePromise, "app"> & Additional>>); - onBuilt?: (app: App) => MaybePromise; - beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; - buildConfig?: Parameters[0]; - } & Additional + CreateAppConfig & + Omit & { + app?: + | Omit, "app"> + | ((args: Args) => MaybePromise, "app">>); + onBuilt?: (app: App) => MaybePromise; + beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; + buildConfig?: Parameters[0]; + } >; export type FrameworkBkndConfig = BkndConfig; diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 50a82a8..c6de63f 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -19,7 +19,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ if (!cleanRequest) return req; const url = new URL(req.url); - cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k)); + cleanRequest?.searchParams?.forEach((k) => { + url.searchParams.delete(k); + }); if (isNode()) { return new Request(url.toString(), { diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 83feba8..d85d197 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -24,7 +24,7 @@ export async function createApp( path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), ); if (relativeDistPath) { - console.warn("relativeDistPath is deprecated, please use distPath instead"); + $console.warn("relativeDistPath is deprecated, please use distPath instead"); } registerLocalMediaAdapter(); diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index ed2e1aa..b20822a 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -67,7 +67,10 @@ export async function startServer( $console.info("Server listening on", url); if (options.open) { - await open(url); + const p = await open(url, { wait: false }); + p.on("error", () => { + $console.warn("Couldn't open url in browser"); + }); } } diff --git a/app/src/core/drivers/email/plunk.spec.ts b/app/src/core/drivers/email/plunk.spec.ts new file mode 100644 index 0000000..82fb544 --- /dev/null +++ b/app/src/core/drivers/email/plunk.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "bun:test"; +import { plunkEmail } from "./plunk"; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("plunk", () => { + it("should throw on failed", async () => { + const driver = plunkEmail({ apiKey: "invalid" }); + expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); + }); + + it("should send an email", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: undefined, // Default to what Plunk sets + }); + const response = await driver.send( + "help@bknd.io", + "Test Email from Plunk", + "This is a test email", + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + expect(response.emails).toBeDefined(); + expect(response.timestamp).toBeDefined(); + }); + + it("should send HTML email", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: undefined, + }); + const htmlBody = "

Test Email

This is a test email

"; + const response = await driver.send( + "help@bknd.io", + "HTML Test", + htmlBody, + ); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); + + it("should send with text and html", async () => { + const driver = plunkEmail({ + apiKey: process.env.PLUNK_API_KEY!, + from: undefined, + }); + const response = await driver.send("test@example.com", "Test Email", { + text: "help@bknd.io", + html: "

This is HTML

", + }); + expect(response).toBeDefined(); + expect(response.success).toBe(true); + }); +}); diff --git a/app/src/core/drivers/email/plunk.ts b/app/src/core/drivers/email/plunk.ts new file mode 100644 index 0000000..a3c7761 --- /dev/null +++ b/app/src/core/drivers/email/plunk.ts @@ -0,0 +1,70 @@ +import type { IEmailDriver } from "./index"; + +export type PlunkEmailOptions = { + apiKey: string; + host?: string; + from?: string; +}; + +export type PlunkEmailSendOptions = { + subscribed?: boolean; + name?: string; + from?: string; + reply?: string; + headers?: Record; +}; + +export type PlunkEmailResponse = { + success: boolean; + emails: Array<{ + contact: { + id: string; + email: string; + }; + email: string; + }>; + timestamp: string; +}; + +export const plunkEmail = ( + config: PlunkEmailOptions, +): IEmailDriver => { + const host = config.host ?? "https://api.useplunk.com/v1/send"; + const from = config.from; + + return { + send: async ( + to: string, + subject: string, + body: string | { text: string; html: string }, + options?: PlunkEmailSendOptions, + ) => { + const payload: any = { + from, + to, + subject, + }; + + if (typeof body === "string") { + payload.body = body; + } else { + payload.body = body.html; + } + + const res = await fetch(host, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${config.apiKey}`, + }, + body: JSON.stringify({ ...payload, ...options }), + }); + + if (!res.ok) { + throw new Error(`Plunk API error: ${await res.text()}`); + } + + return (await res.json()) as PlunkEmailResponse; + }, + }; +}; diff --git a/app/src/core/drivers/index.ts b/app/src/core/drivers/index.ts index da356b7..963b9c4 100644 --- a/app/src/core/drivers/index.ts +++ b/app/src/core/drivers/index.ts @@ -5,3 +5,4 @@ export type { IEmailDriver } from "./email"; export { resendEmail } from "./email/resend"; export { sesEmail } from "./email/ses"; export { mailchannelsEmail } from "./email/mailchannels"; +export { plunkEmail } from "./email/plunk"; diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts index 76a067e..7e8bc75 100644 --- a/app/src/core/events/EventListener.ts +++ b/app/src/core/events/EventListener.ts @@ -1,3 +1,4 @@ +import type { MaybePromise } from "bknd"; import type { Event } from "./Event"; import type { EventClass } from "./EventManager"; @@ -7,7 +8,7 @@ export type ListenerMode = (typeof ListenerModes)[number]; export type ListenerHandler> = ( event: E, slug: string, -) => E extends Event ? R | Promise : never; +) => E extends Event ? MaybePromise : never; export class EventListener { mode: ListenerMode = "async"; diff --git a/app/src/core/utils/objects.ts b/app/src/core/utils/objects.ts index 33c6a43..afdf183 100644 --- a/app/src/core/utils/objects.ts +++ b/app/src/core/utils/objects.ts @@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record { export function omitKeys( obj: T, - keys_: readonly K[], + keys_: readonly K[] | K[] | string[], ): Omit> { - const keys = new Set(keys_); + const keys = new Set(keys_ as readonly K[]); const result = {} as Omit>; for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) { if (!keys.has(key as K)) { diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 5c578d6..e43a8ad 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -79,6 +79,22 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) { } } +export async function threwAsync(fn: Promise, instance?: new (...args: any[]) => Error) { + try { + await fn; + return false; + } catch (e) { + if (instance) { + if (e instanceof instance) { + return true; + } + // if instance given but not what expected, throw + throw e; + } + return true; + } +} + export async function $waitUntil( message: string, condition: () => MaybePromise, diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts index c052331..6d0d4ed 100644 --- a/app/src/core/utils/strings.ts +++ b/app/src/core/utils/strings.ts @@ -120,17 +120,14 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean } export function slugify(str: string): string { - return ( - String(str) - .normalize("NFKD") // split accented characters into their base characters and diacritical marks - // biome-ignore lint/suspicious/noMisleadingCharacterClass: - .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. - .trim() // trim leading or trailing whitespace - .toLowerCase() // convert to lowercase - .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters - .replace(/\s+/g, "-") // replace spaces with hyphens - .replace(/-+/g, "-") // remove consecutive hyphens - ); + return String(str) + .normalize("NFKD") // split accented characters into their base characters and diacritical marks + .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block. + .trim() // trim leading or trailing whitespace + .toLowerCase() // convert to lowercase + .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters + .replace(/\s+/g, "-") // replace spaces with hyphens + .replace(/-+/g, "-"); // remove consecutive hyphens } export function truncate(str: string, length = 50, end = "..."): string { diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 082ae0c..531be0c 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -96,6 +96,9 @@ export class DataController extends Controller { // read entity schema hono.get( "/schema.json", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -124,6 +127,9 @@ export class DataController extends Controller { // read schema hono.get( "/schemas/:entity/:context?", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), @@ -161,7 +167,7 @@ export class DataController extends Controller { hono.get( "/types", permission(SystemPermissions.schemaRead, { - context: (c) => ({ module: "data" }), + context: (_c) => ({ module: "data" }), }), describeRoute({ summary: "Retrieve data typescript definitions", @@ -182,6 +188,9 @@ export class DataController extends Controller { */ hono.get( "/info/:entity", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "data" }), + }), permission(DataPermissions.entityRead, { context: (c) => ({ entity: c.req.param("entity") }), }), diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 3d8f432..218f25e 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -103,6 +103,7 @@ export class Repository 0) { for (const entry of options.join) { const related = this.em.relationOf(entity.name, entry); @@ -127,12 +128,28 @@ export class Repository { if (field.includes(".")) { const [alias, prop] = field.split(".") as [string, string]; - if (!aliases.includes(alias)) { + // check aliases first (added joins) + if (aliases.includes(alias)) { + this.checkIndex(alias, prop, "where"); + return !this.em.entity(alias).getField(prop); + } + // check if alias (entity) exists + if (!this.em.hasEntity(alias)) { return true; } + // check related fields for auto join + const related = this.em.relationOf(entity.name, alias); + if (related) { + const other = related.other(entity); + if (other.entity.getField(prop)) { + // if related field is found, add join to validated options + validated.join?.push(alias); + this.checkIndex(alias, prop, "where"); + return false; + } + } - this.checkIndex(alias, prop, "where"); - return !this.em.entity(alias).getField(prop); + return true; } this.checkIndex(entity.name, field, "where"); diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 5e9fd6a..07b4ac6 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -4,6 +4,7 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { RepoQuery } from "data/server/query"; import { InvalidSearchParamsException } from "data/errors"; import type { Entity, EntityManager, RepositoryQB } from "data/entities"; +import { $console } from "bknd/utils"; export class WithBuilder { static addClause( @@ -13,7 +14,7 @@ export class WithBuilder { withs: RepoQuery["with"], ) { if (!withs || !isObject(withs)) { - console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); + $console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); return qb; } @@ -37,9 +38,7 @@ export class WithBuilder { let subQuery = relation.buildWith(entity, ref)(eb); if (query) { subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, { - ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter( - Boolean, - ) as any, + ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any, }); } @@ -57,7 +56,7 @@ export class WithBuilder { static validateWiths(em: EntityManager, entity: string, withs: RepoQuery["with"]) { let depth = 0; if (!withs || !isObject(withs)) { - withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); + withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); return depth; } diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index fed47bf..b182ac1 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -26,7 +26,12 @@ export class JsonSchemaField< constructor(name: string, config: Partial) { super(name, config); - this.validator = new Validator({ ...this.getJsonSchema() }); + + // make sure to hand over clean json + const schema = this.getJsonSchema(); + this.validator = new Validator( + typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {}, + ); } protected getSchema() { diff --git a/app/src/data/fields/NumberField.ts b/app/src/data/fields/NumberField.ts index b2e4516..0f7481f 100644 --- a/app/src/data/fields/NumberField.ts +++ b/app/src/data/fields/NumberField.ts @@ -52,7 +52,7 @@ export class NumberField extends Field< switch (context) { case "submit": - return Number.parseInt(value); + return Number.parseInt(value, 10); } return value; diff --git a/app/src/data/helper.ts b/app/src/data/helper.ts index 12c531b..12120d8 100644 --- a/app/src/data/helper.ts +++ b/app/src/data/helper.ts @@ -28,7 +28,7 @@ export function getChangeSet( const value = _value === "" ? null : _value; // normalize to null if undefined - const newValue = field.getValue(value, "submit") || null; + const newValue = field.getValue(value, "submit") ?? null; // @todo: add typing for "action" if (action === "create" || newValue !== data[key]) { acc[key] = newValue; diff --git a/app/src/data/prototype/index.ts b/app/src/data/prototype/index.ts index 43df6a1..0741f95 100644 --- a/app/src/data/prototype/index.ts +++ b/app/src/data/prototype/index.ts @@ -289,7 +289,7 @@ class EntityManagerPrototype> extends En super(Object.values(__entities), new DummyConnection(), relations, indices); } - withConnection(connection: Connection): EntityManager> { + withConnection(connection: Connection): EntityManager> { return new EntityManager(this.entities, connection, this.relations.all, this.indices); } } diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts index 30e4dc3..6c147a3 100644 --- a/app/src/modes/code.ts +++ b/app/src/modes/code.ts @@ -10,16 +10,19 @@ export type CodeMode = AdapterConfig extends B ? BkndModeConfig : never; -export function code(config: BkndCodeModeConfig): BkndConfig { +export function code< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(codeConfig: CodeMode): BkndConfig { return { - ...config, + ...codeConfig, app: async (args) => { const { config: appConfig, plugins, isProd, syncSchemaOptions, - } = await makeModeConfig(config, args); + } = await makeModeConfig(codeConfig, args); if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { $console.warn("You should not set a different mode than `db` when using code mode"); diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts index 7a8022b..40fca8c 100644 --- a/app/src/modes/hybrid.ts +++ b/app/src/modes/hybrid.ts @@ -1,6 +1,6 @@ import type { BkndConfig } from "bknd/adapter"; import { makeModeConfig, type BkndModeConfig } from "./shared"; -import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import { getDefaultConfig, type MaybePromise, type Merge } from "bknd"; import type { DbModuleManager } from "modules/db/DbModuleManager"; import { invariant, $console } from "bknd/utils"; @@ -9,7 +9,7 @@ export type BkndHybridModeOptions = { * Reader function to read the configuration from the file system. * This is required for hybrid mode to work. */ - reader?: (path: string) => MaybePromise; + reader?: (path: string) => MaybePromise; /** * Provided secrets to be merged into the configuration */ @@ -23,42 +23,36 @@ export type HybridMode = AdapterConfig extends ? BkndModeConfig> : never; -export function hybrid({ - configFilePath = "bknd-config.json", - ...rest -}: HybridBkndConfig): BkndConfig { +export function hybrid< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(hybridConfig: HybridMode): BkndConfig { return { - ...rest, - config: undefined, + ...hybridConfig, app: async (args) => { const { config: appConfig, isProd, plugins, syncSchemaOptions, - } = await makeModeConfig( - { - ...rest, - configFilePath, - }, - args, - ); + } = await makeModeConfig(hybridConfig, args); + + const configFilePath = appConfig.configFilePath ?? "bknd-config.json"; if (appConfig?.options?.mode && appConfig?.options?.mode !== "db") { $console.warn("You should not set a different mode than `db` when using hybrid mode"); } invariant( typeof appConfig.reader === "function", - "You must set the `reader` option when using hybrid mode", + "You must set a `reader` option when using hybrid mode", ); - let fileConfig: ModuleConfigs; - try { - fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs; - } catch (e) { - const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs; - await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2)); - fileConfig = defaultConfig; + const fileContent = await appConfig.reader?.(configFilePath); + let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent; + if (!fileConfig) { + $console.warn("No config found, using default config"); + fileConfig = getDefaultConfig(); + await appConfig.writer?.(configFilePath, JSON.stringify(fileConfig, null, 2)); } return { @@ -80,6 +74,13 @@ export function hybrid({ skipValidation: isProd, // secrets are required for hybrid mode secrets: appConfig.secrets, + onModulesBuilt: async (ctx) => { + if (ctx.flags.sync_required && !isProd && syncSchemaOptions.force) { + $console.log("[hybrid] syncing schema"); + await ctx.em.schema().sync(syncSchemaOptions); + } + await appConfig?.options?.manager?.onModulesBuilt?.(ctx); + }, ...appConfig?.options?.manager, }, }, diff --git a/app/src/modes/shared.ts b/app/src/modes/shared.ts index f1bc4ff..6b226c2 100644 --- a/app/src/modes/shared.ts +++ b/app/src/modes/shared.ts @@ -1,7 +1,7 @@ import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd"; import { syncTypes, syncConfig } from "bknd/plugins"; import { syncSecrets } from "plugins/dev/sync-secrets.plugin"; -import { invariant, $console } from "bknd/utils"; +import { $console } from "bknd/utils"; export type BkndModeOptions = { /** @@ -56,6 +56,14 @@ export type BkndModeConfig = BkndConfig< Merge >; +function _isProd() { + try { + return process.env.NODE_ENV === "production"; + } catch (_e) { + return false; + } +} + export async function makeModeConfig< Args = any, Config extends BkndModeConfig = BkndModeConfig, @@ -69,25 +77,24 @@ export async function makeModeConfig< if (typeof config.isProduction !== "boolean") { $console.warn( - "You should set `isProduction` option when using managed modes to prevent accidental issues", + "You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to", + _isProd(), ); } - invariant( - typeof config.writer === "function", - "You must set the `writer` option when using managed modes", - ); + let needsWriter = false; const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; - const isProd = config.isProduction; - const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const isProd = config.isProduction ?? _isProd(); + const plugins = config?.options?.plugins ?? ([] as AppPlugin[]); + const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncSchemaOptions = typeof config.syncSchema === "object" ? config.syncSchema : { - force: config.syncSchema !== false, - drop: true, + force: syncFallback, + drop: syncFallback, }; if (!isProd) { @@ -95,6 +102,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-types")) { throw new Error("You have to unregister the `syncTypes` plugin"); } + needsWriter = true; plugins.push( syncTypes({ enabled: true, @@ -114,6 +122,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-config")) { throw new Error("You have to unregister the `syncConfig` plugin"); } + needsWriter = true; plugins.push( syncConfig({ enabled: true, @@ -142,6 +151,7 @@ export async function makeModeConfig< .join("."); } + needsWriter = true; plugins.push( syncSecrets({ enabled: true, @@ -174,6 +184,10 @@ export async function makeModeConfig< } } + if (needsWriter && typeof config.writer !== "function") { + $console.warn("You must set a `writer` function, attempts to write will fail"); + } + return { config, isProd, diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 8406eaa..706a8fd 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -223,7 +223,7 @@ export class ModuleManager { } extractSecrets() { - const moduleConfigs = structuredClone(this.configs()); + const moduleConfigs = JSON.parse(JSON.stringify(this.configs())); const secrets = { ...this.options?.secrets }; const extractedKeys: string[] = []; diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts index 8af95e8..51e78ff 100644 --- a/app/src/modules/db/DbModuleManager.ts +++ b/app/src/modules/db/DbModuleManager.ts @@ -1,4 +1,4 @@ -import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils"; +import { mark, stripMark, $console, s, setPath } from "bknd/utils"; import { BkndError } from "core/errors"; import * as $diff from "core/object/diff"; import type { Connection } from "data/connection"; @@ -290,13 +290,12 @@ export class DbModuleManager extends ModuleManager { updated_at: new Date(), }); } - } else if (e instanceof TransformPersistFailedException) { - $console.error("ModuleManager: Cannot save invalid config"); - this.revertModules(); - throw e; } else { + if (e instanceof TransformPersistFailedException) { + $console.error("ModuleManager: Cannot save invalid config"); + } $console.error("ModuleManager: Aborting"); - this.revertModules(); + await this.revertModules(); throw e; } } diff --git a/app/src/modules/permissions/index.ts b/app/src/modules/permissions/index.ts index 152072d..0a58180 100644 --- a/app/src/modules/permissions/index.ts +++ b/app/src/modules/permissions/index.ts @@ -33,3 +33,5 @@ export const schemaRead = new Permission( ); export const build = new Permission("system.build"); export const mcp = new Permission("system.mcp"); +export const info = new Permission("system.info"); +export const openapi = new Permission("system.openapi"); diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 9434309..8352982 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -105,7 +105,10 @@ export class AppServer extends Module { if (err instanceof Error) { if (isDebug()) { - return c.json({ error: err.message, stack: err.stack }, 500); + return c.json( + { error: err.message, stack: err.stack?.split("\n").map((line) => line.trim()) }, + 500, + ); } } diff --git a/app/src/modules/server/SystemController.ts b/app/src/modules/server/SystemController.ts index 3ae6cd2..45988ad 100644 --- a/app/src/modules/server/SystemController.ts +++ b/app/src/modules/server/SystemController.ts @@ -1,5 +1,3 @@ -/// - import type { App } from "App"; import { datetimeStringLocal, @@ -125,7 +123,7 @@ export class SystemController extends Controller { private registerConfigController(client: Hono): void { const { permission } = this.middlewares; // don't add auth again, it's already added in getController - const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */ + const hono = this.create(); if (!this.app.isReadOnly()) { const manager = this.app.modules as DbModuleManager; @@ -317,6 +315,11 @@ export class SystemController extends Controller { summary: "Get the config for a module", tags: ["system"], }), + permission(SystemPermissions.configRead, { + context: (c) => ({ + module: c.req.param("module"), + }), + }), mcpTool("system_config", { annotations: { readOnlyHint: true, @@ -354,7 +357,7 @@ export class SystemController extends Controller { override getController() { const { permission, auth } = this.middlewares; - const hono = this.create().use(auth()); + const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {})); this.registerConfigController(hono); @@ -429,6 +432,9 @@ export class SystemController extends Controller { hono.get( "/permissions", + permission(SystemPermissions.schemaRead, { + context: (_c) => ({ module: "auth" }), + }), describeRoute({ summary: "Get the permissions", tags: ["system"], @@ -441,6 +447,7 @@ export class SystemController extends Controller { hono.post( "/build", + permission(SystemPermissions.build, {}), describeRoute({ summary: "Build the app", tags: ["system"], @@ -471,6 +478,7 @@ export class SystemController extends Controller { hono.get( "/info", + permission(SystemPermissions.info, {}), mcpTool("system_info"), describeRoute({ summary: "Get the server info", @@ -504,6 +512,7 @@ export class SystemController extends Controller { hono.get( "/openapi.json", + permission(SystemPermissions.openapi, {}), openAPISpecs(this.ctx.server, { info: { title: "bknd API", @@ -511,7 +520,11 @@ export class SystemController extends Controller { }, }), ); - hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" })); + hono.get( + "/swagger", + permission(SystemPermissions.openapi, {}), + swaggerUI({ url: "/api/system/openapi.json" }), + ); return hono; } diff --git a/app/src/plugins/auth/email-otp.plugin.spec.ts b/app/src/plugins/auth/email-otp.plugin.spec.ts new file mode 100644 index 0000000..cadc0a3 --- /dev/null +++ b/app/src/plugins/auth/email-otp.plugin.spec.ts @@ -0,0 +1,683 @@ +import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test"; +import { emailOTP } from "./email-otp.plugin"; +import { createApp } from "core/test/utils"; +import { disableConsoleLog, enableConsoleLog } from "core/utils/test"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); + +describe("otp plugin", () => { + test("should not work if auth is not enabled", async () => { + const app = createApp({ + options: { + plugins: [emailOTP({ showActualErrors: true })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + }); + + test("should require email driver if sendEmail is true", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP()], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(404); + + { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + }, + }); + await app.build(); + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + } + }); + + test("should prevent mutations of the OTP entity", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + drivers: { + email: { + send: async () => {}, + }, + }, + plugins: [emailOTP({ showActualErrors: true })], + }, + }); + await app.build(); + + const payload = { + email: "test@test.com", + code: "123456", + action: "login", + created_at: new Date(), + expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24), + used_at: null, + }; + + expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow(); + expect( + await app + .getApi() + .data.createOne("users_otp", payload) + .then((r) => r.ok), + ).toBe(false); + }); + + test("should generate a token", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ showActualErrors: true })], + drivers: { + email: { + send: async (to) => { + expect(to).toBe("test@test.com"); + called(); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("login"); + expect(data.data.expires_at).toBeDefined(); + + { + const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" }); + expect(data?.code).toBeDefined(); + expect(data?.code?.length).toBe(6); + expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe( + true, + ); + expect(data?.email).toBe("test@test.com"); + } + expect(called).toHaveBeenCalled(); + }); + + test("should login with a code", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (to, _subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should register with a code", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (to, _subject, body) => { + expect(to).toBe("test@test.com"); + code = String(body); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + const data = (await res.json()) as any; + expect(data.sent).toBe(true); + expect(data.data.email).toBe("test@test.com"); + expect(data.data.action).toBe("register"); + expect(data.data.expires_at).toBeDefined(); + + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + expect(res.headers.get("set-cookie")).toBeDefined(); + const userData = (await res.json()) as any; + expect(userData.user.email).toBe("test@test.com"); + expect(userData.token).toBeDefined(); + } + }); + + test("should not send email if sendEmail is false", async () => { + const called = mock(() => null); + const app = createApp({ + config: { + auth: { + enabled: true, + }, + }, + options: { + plugins: [emailOTP({ sendEmail: false })], + drivers: { + email: { + send: async () => { + called(); + }, + }, + }, + }, + }); + await app.build(); + + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(res.status).toBe(201); + expect(called).not.toHaveBeenCalled(); + }); + + test("should reject invalid codes", async () => { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // First send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Try to use an invalid code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: "999999" }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + }); + + test("should reject code reuse", async () => { + let code = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async (_to, _subject, body) => { + code = String(body); + }, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Use the code successfully + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(200); + } + + // Try to use the same code again + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should reject expired codes", async () => { + // Set a fixed system time + const baseTime = Date.now(); + setSystemTime(new Date(baseTime)); + + try { + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + ttl: 1, // 1 second TTL + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a code + const sendRes = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + expect(sendRes.status).toBe(201); + + // Get the code from the database + const { data: otpData } = await app.em + .fork() + .repo("users_otp") + .findOne({ email: "test@test.com" }); + expect(otpData?.code).toBeDefined(); + + // Advance system time by more than 1 second to expire the code + setSystemTime(new Date(baseTime + 1100)); + + // Try to use the expired code + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: otpData?.code }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } finally { + // Reset system time + setSystemTime(); + } + }); + + test("should reject codes with different actions", async () => { + let loginCode = ""; + let registerCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + + // Send a login code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the login code + const { data: loginOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } }); + loginCode = loginOtp?.code || ""; + + // Send a register code + await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the register code + const { data: registerOtp } = await app + .getApi() + .data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } }); + registerCode = registerOtp?.code || ""; + + // Try to use login code for register + { + const res = await app.server.request("/api/auth/otp/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: loginCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // Try to use register code for login + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: registerCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + }); + + test("should invalidate previous codes when sending new code", async () => { + let firstCode = ""; + let secondCode = ""; + + const app = createApp({ + config: { + auth: { + enabled: true, + jwt: { + secret: "test", + }, + }, + }, + options: { + plugins: [ + emailOTP({ + showActualErrors: true, + generateEmail: (otp) => ({ subject: "test", body: otp.code }), + }), + ], + drivers: { + email: { + send: async () => {}, + }, + }, + seed: async (ctx) => { + await ctx.app.createUser({ email: "test@test.com", password: "12345678" }); + }, + }, + }); + await app.build(); + const em = app.em.fork(); + + // Send first code + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the first code + const { data: firstOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + firstCode = firstOtp?.code || ""; + expect(firstCode).toBeDefined(); + + // Send second code (should invalidate the first) + await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com" }), + }); + + // Get the second code + const { data: secondOtp } = await em + .repo("users_otp") + .findOne({ email: "test@test.com", action: "login" }); + secondCode = secondOtp?.code || ""; + expect(secondCode).toBeDefined(); + expect(secondCode).not.toBe(firstCode); + + // Try to use the first code (should fail as it's been invalidated) + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: firstCode }), + }); + expect(res.status).toBe(400); + const error = await res.json(); + expect(error).toBeDefined(); + } + + // The second code should work + { + const res = await app.server.request("/api/auth/otp/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: "test@test.com", code: secondCode }), + }); + expect(res.status).toBe(200); + } + }); +}); diff --git a/app/src/plugins/auth/email-otp.plugin.ts b/app/src/plugins/auth/email-otp.plugin.ts new file mode 100644 index 0000000..13f9a93 --- /dev/null +++ b/app/src/plugins/auth/email-otp.plugin.ts @@ -0,0 +1,387 @@ +import { + datetime, + em, + entity, + enumm, + Exception, + text, + type App, + type AppPlugin, + type DB, + type FieldSchema, + type MaybePromise, + type EntityConfig, + DatabaseEvents, +} from "bknd"; +import { + invariant, + s, + jsc, + HttpStatus, + threwAsync, + randomString, + $console, + pickKeys, +} from "bknd/utils"; +import { Hono } from "hono"; + +export type EmailOTPPluginOptions = { + /** + * Customize code generation. If not provided, a random 6-digit code will be generated. + */ + generateCode?: (user: Pick) => string; + + /** + * The base path for the API endpoints. + * @default "/api/auth/otp" + */ + apiBasePath?: string; + + /** + * The TTL for the OTP tokens in seconds. + * @default 600 (10 minutes) + */ + ttl?: number; + + /** + * The name of the OTP entity. + * @default "users_otp" + */ + entity?: string; + + /** + * The config for the OTP entity. + */ + entityConfig?: EntityConfig; + + /** + * Customize email content. If not provided, a default email will be sent. + */ + generateEmail?: ( + otp: EmailOTPFieldSchema, + ) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>; + + /** + * Enable debug mode for error messages. + * @default false + */ + showActualErrors?: boolean; + + /** + * Allow direct mutations (create/update) of OTP codes outside of this plugin, + * e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows. + * @default false + */ + allowExternalMutations?: boolean; + + /** + * Whether to send the email with the OTP code. + * @default true + */ + sendEmail?: boolean; +}; + +const otpFields = { + action: enumm({ + enum: ["login", "register"], + }), + code: text().required(), + email: text().required(), + created_at: datetime(), + expires_at: datetime().required(), + used_at: datetime(), +}; + +export type EmailOTPFieldSchema = FieldSchema; + +class OTPError extends Exception { + override name = "OTPError"; + override code = HttpStatus.BAD_REQUEST; +} + +export function emailOTP({ + generateCode: _generateCode, + apiBasePath = "/api/auth/otp", + ttl = 600, + entity: entityName = "users_otp", + entityConfig, + generateEmail: _generateEmail, + showActualErrors = false, + allowExternalMutations = false, + sendEmail = true, +}: EmailOTPPluginOptions = {}): AppPlugin { + return (app: App) => { + return { + name: "email-otp", + schema: () => + em( + { + [entityName]: entity( + entityName, + otpFields, + { + name: "Users OTP", + sort_dir: "desc", + primary_format: app.module.data.config.default_primary_format, + ...entityConfig, + }, + "generated", + ), + }, + ({ index }, schema) => { + const otp = schema[entityName]!; + index(otp).on(["email", "expires_at", "code"]); + }, + ), + onBuilt: async () => { + const auth = app.module.auth; + invariant(auth && auth.enabled === true, "Auth is not enabled"); + invariant(!sendEmail || app.drivers?.email, "Email driver is not registered"); + + const generateCode = + _generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString()); + const generateEmail = + _generateEmail ?? + ((otp: EmailOTPFieldSchema) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + })); + const em = app.em.fork(); + + const hono = new Hono() + .post( + "/login", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string({ minLength: 1 }).optional(), + }), + ), + jsc("query", s.object({ redirect: s.string().optional() })), + async (c) => { + const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); + const user = await findUser(app, email); + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "login", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); + } else { + const otpData = await invalidateAndGenerateCode( + app, + { generateCode, ttl, entity: entityName }, + user, + "login", + ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } + + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); + } + }, + ) + .post( + "/register", + jsc( + "json", + s.object({ + email: s.string({ format: "email" }), + code: s.string({ minLength: 1 }).optional(), + }), + ), + jsc("query", s.object({ redirect: s.string().optional() })), + async (c) => { + const { email, code } = c.req.valid("json"); + const { redirect } = c.req.valid("query"); + + // throw if user exists + if (!(await threwAsync(findUser(app, email)))) { + throw new Exception("User already exists", HttpStatus.BAD_REQUEST); + } + + if (code) { + const otpData = await getValidatedCode( + app, + entityName, + email, + code, + "register", + ); + await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() }); + + const user = await app.createUser({ + email, + password: randomString(32, true), + }); + + const jwt = await auth.authenticator.jwt(user); + // @ts-expect-error private method + return auth.authenticator.respondWithUser( + c, + { user, token: jwt }, + { redirect }, + ); + } else { + const otpData = await invalidateAndGenerateCode( + app, + { generateCode, ttl, entity: entityName }, + { email }, + "register", + ); + if (sendEmail) { + await sendCode(app, otpData, { generateEmail }); + } + + return c.json( + { + sent: true, + data: pickKeys(otpData, ["email", "action", "expires_at"]), + }, + HttpStatus.CREATED, + ); + } + }, + ) + .onError((err) => { + if (showActualErrors || err instanceof OTPError) { + throw err; + } + + throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST); + }); + + app.server.route(apiBasePath, hono); + + if (allowExternalMutations !== true) { + registerListeners(app, entityName); + } + }, + }; + }; +} + +async function findUser(app: App, email: string) { + const user_entity = app.module.auth.config.entity_name as "users"; + const { data: user } = await app.em.repo(user_entity).findOne({ email }); + if (!user) { + throw new Exception("User not found", HttpStatus.BAD_REQUEST); + } + + return user; +} + +async function invalidateAndGenerateCode( + app: App, + opts: Required>, + user: Pick, + action: EmailOTPFieldSchema["action"], +) { + const { generateCode, ttl, entity: entityName } = opts; + const newCode = generateCode?.(user); + if (!newCode) { + throw new OTPError("Failed to generate code"); + } + + await invalidateAllUserCodes(app, entityName, user.email, ttl); + const { data: otpData } = await app.em + .fork() + .mutator(entityName) + .insertOne({ + code: newCode, + email: user.email, + action, + created_at: new Date(), + expires_at: new Date(Date.now() + ttl * 1000), + }); + + $console.log("[OTP Code]", newCode); + + return otpData; +} + +async function sendCode( + app: App, + otpData: EmailOTPFieldSchema, + opts: Required>, +) { + const { generateEmail } = opts; + const { subject, body } = await generateEmail(otpData); + await app.drivers?.email?.send(otpData.email, subject, body); +} + +async function getValidatedCode( + app: App, + entityName: string, + email: string, + code: string, + action: EmailOTPFieldSchema["action"], +) { + invariant(email, "[OTP Plugin]: Email is required"); + invariant(code, "[OTP Plugin]: Code is required"); + const em = app.em.fork(); + const { data: otpData } = await em.repo(entityName).findOne({ email, code, action }); + if (!otpData) { + throw new OTPError("Invalid code"); + } + + if (otpData.expires_at < new Date()) { + throw new OTPError("Code expired"); + } + + if (otpData.used_at) { + throw new OTPError("Code already used"); + } + + return otpData; +} + +async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) { + invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0"); + invariant(email, "[OTP Plugin]: Email is required"); + const em = app.em.fork(); + await em + .mutator(entityName) + .updateWhere( + { expires_at: new Date(Date.now() - 1000) }, + { email, used_at: { $isnull: true } }, + ); +} + +function registerListeners(app: App, entityName: string) { + [DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => { + app.emgr.onEvent( + event, + (e: { params: { entity: { name: string } } }) => { + if (e.params.entity.name === entityName) { + throw new OTPError("Mutations of the OTP entity are not allowed"); + } + }, + { + mode: "sync", + id: "bknd-email-otp", + }, + ); + }); +} diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts index b0090ff..fcb23c1 100644 --- a/app/src/plugins/index.ts +++ b/app/src/plugins/index.ts @@ -8,3 +8,4 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin"; export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin"; export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin"; export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin"; +export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin"; diff --git a/app/src/ui/client/BkndProvider.tsx b/app/src/ui/client/BkndProvider.tsx index 3ac5d5d..056e52d 100644 --- a/app/src/ui/client/BkndProvider.tsx +++ b/app/src/ui/client/BkndProvider.tsx @@ -9,7 +9,7 @@ import { useState, type ReactNode, } from "react"; -import { useApi } from "ui/client"; +import { useApi } from "bknd/client"; import { type TSchemaActions, getSchemaActions } from "./schema/actions"; import { AppReduced } from "./utils/AppReduced"; import { Message } from "ui/components/display/Message"; diff --git a/app/src/ui/client/ClientProvider.tsx b/app/src/ui/client/ClientProvider.tsx index 88a54c1..21866c3 100644 --- a/app/src/ui/client/ClientProvider.tsx +++ b/app/src/ui/client/ClientProvider.tsx @@ -14,18 +14,20 @@ const ClientContext = createContext(undefined!); export type ClientProviderProps = { children?: ReactNode; baseUrl?: string; + api?: Api; } & ApiOptions; export const ClientProvider = ({ children, host, baseUrl: _baseUrl = host, + api: _api, ...props }: ClientProviderProps) => { const winCtx = useBkndWindowContext(); const _ctx = useClientContext(); let actualBaseUrl = _baseUrl ?? _ctx?.baseUrl ?? ""; - let user: any = undefined; + let user: any; if (winCtx) { user = winCtx.user; @@ -40,6 +42,7 @@ export const ClientProvider = ({ const apiProps = { user, ...props, host: actualBaseUrl }; const api = useMemo( () => + _api ?? new Api({ ...apiProps, verbose: isDebug(), @@ -50,7 +53,7 @@ export const ClientProvider = ({ } }, }), - [JSON.stringify(apiProps)], + [_api, JSON.stringify(apiProps)], ); const [authState, setAuthState] = useState | undefined>(api.getAuthState()); @@ -64,6 +67,10 @@ export const ClientProvider = ({ export const useApi = (host?: ApiOptions["host"]): Api => { const context = useContext(ClientContext); + if (!context) { + throw new Error("useApi must be used within a ClientProvider"); + } + if (!context?.api || (host && host.length > 0 && host !== context.baseUrl)) { return new Api({ host: host ?? "" }); } diff --git a/app/src/ui/client/api/use-api.ts b/app/src/ui/client/api/use-api.ts index 573b990..a93aaf2 100644 --- a/app/src/ui/client/api/use-api.ts +++ b/app/src/ui/client/api/use-api.ts @@ -2,7 +2,7 @@ import type { Api } from "Api"; import { FetchPromise, type ModuleApi, type ResponseObject } from "modules/ModuleApi"; import useSWR, { type SWRConfiguration, useSWRConfig, type Middleware, type SWRHook } from "swr"; import useSWRInfinite from "swr/infinite"; -import { useApi } from "ui/client"; +import { useApi } from "../ClientProvider"; import { useState } from "react"; export const useApiQuery = < diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index f53798c..7edeaf1 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -8,9 +8,9 @@ import type { ModuleApi, } from "bknd"; import { objectTransform, encodeSearch } from "bknd/utils"; -import type { Insertable, Selectable, Updateable } from "kysely"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; -import { type Api, useApi } from "ui/client"; +import { type Api, useApi } from "bknd/client"; export class UseEntityApiError extends Error { constructor( @@ -33,6 +33,7 @@ interface UseEntityReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined, Data = Entity extends keyof DB ? DB[Entity] : EntityData, + ActualId = Data extends { id: infer I } ? (I extends Generated ? T : I) : never, Response = ResponseObject>>, > { create: (input: Insertable) => Promise; @@ -42,9 +43,11 @@ interface UseEntityReturn< ResponseObject[] : Selectable>> >; update: Id extends undefined - ? (input: Updateable, id: Id) => Promise + ? (input: Updateable, id: ActualId) => Promise : (input: Updateable) => Promise; - _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; + _delete: Id extends undefined + ? (id: PrimaryFieldType) => Promise + : () => Promise; } export const useEntity = < diff --git a/app/src/ui/client/index.ts b/app/src/ui/client/index.ts index 2fb520e..f611752 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -4,6 +4,7 @@ export { type ClientProviderProps, useApi, useBaseUrl, + useClientContext } from "./ClientProvider"; export * from "./api/use-api"; diff --git a/app/src/ui/client/schema/auth/use-auth.ts b/app/src/ui/client/schema/auth/use-auth.ts index 291c963..3db502d 100644 --- a/app/src/ui/client/schema/auth/use-auth.ts +++ b/app/src/ui/client/schema/auth/use-auth.ts @@ -1,7 +1,6 @@ import type { AuthState } from "Api"; import type { AuthResponse } from "bknd"; -import { useApi, useInvalidate } from "ui/client"; -import { useClientContext } from "ui/client/ClientProvider"; +import { useApi, useInvalidate, useClientContext } from "bknd/client"; type LoginData = { email: string; diff --git a/app/src/ui/elements/hooks/use-auth.ts b/app/src/ui/elements/hooks/use-auth.ts index 5907cf6..9bcdf57 100644 --- a/app/src/ui/elements/hooks/use-auth.ts +++ b/app/src/ui/elements/hooks/use-auth.ts @@ -1,9 +1,11 @@ import type { AppAuthSchema } from "auth/auth-schema"; import { useEffect, useState } from "react"; -import { useApi } from "ui/client"; +import { useApi } from "bknd/client"; type AuthStrategyData = Pick; -export const useAuthStrategies = (options?: { baseUrl?: string }): Partial & { +export const useAuthStrategies = (options?: { + baseUrl?: string; +}): Partial & { loading: boolean; } => { const [data, setData] = useState(); diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index b7cb384..896807f 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -14,7 +14,7 @@ import { isFileAccepted } from "bknd/utils"; import { type FileWithPath, useDropzone } from "./use-dropzone"; import { checkMaxReached } from "./helper"; import { DropzoneInner } from "./DropzoneInner"; -import { createDropzoneStore } from "ui/elements/media/dropzone-state"; +import { createDropzoneStore } from "./dropzone-state"; import { useStore } from "zustand"; export type FileState = { diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index 2a99a5f..bd412dc 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -1,9 +1,8 @@ -import type { Api } from "bknd/client"; import type { PrimaryFieldType, RepoQueryIn } from "bknd"; import type { MediaFieldSchema } from "media/AppMedia"; import type { TAppMediaConfig } from "media/media-schema"; import { useId, useEffect, useRef, useState } from "react"; -import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; +import { type Api, useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "bknd/client"; import { useEvent } from "ui/hooks/use-event"; import { Dropzone, type DropzoneProps } from "./Dropzone"; import { mediaItemsToFileStates } from "./helper"; @@ -132,26 +131,24 @@ export function DropzoneContainer({ } return ( - <> - $q.setSize($q.size + 1)} - /> - ) - } - {...props} - /> - + $q.setSize($q.size + 1)} + /> + ) + } + {...props} + /> ); } diff --git a/app/src/ui/elements/media/DropzoneInner.tsx b/app/src/ui/elements/media/DropzoneInner.tsx index 6c3cb87..4e7927c 100644 --- a/app/src/ui/elements/media/DropzoneInner.tsx +++ b/app/src/ui/elements/media/DropzoneInner.tsx @@ -19,8 +19,8 @@ import { } from "react-icons/tb"; import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown"; import { IconButton } from "ui/components/buttons/IconButton"; -import { formatNumber } from "core/utils"; -import type { DropzoneRenderProps, FileState } from "ui/elements"; +import { formatNumber } from "bknd/utils"; +import type { DropzoneRenderProps, FileState } from "./Dropzone"; import { useDropzoneFileState, useDropzoneState } from "./Dropzone"; function handleUploadError(e: unknown) { diff --git a/app/src/ui/layouts/AppShell/Header.tsx b/app/src/ui/layouts/AppShell/Header.tsx index f53beb3..5d978c8 100644 --- a/app/src/ui/layouts/AppShell/Header.tsx +++ b/app/src/ui/layouts/AppShell/Header.tsx @@ -10,7 +10,7 @@ import { TbUser, TbX, } from "react-icons/tb"; -import { useAuth, useBkndWindowContext } from "ui/client"; +import { useAuth, useBkndWindowContext } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { useTheme } from "ui/client/use-theme"; import { Button } from "ui/components/buttons/Button"; diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx index bfce4f1..4ed787c 100644 --- a/app/src/ui/modals/media/MediaInfoModal.tsx +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -1,6 +1,6 @@ import type { ContextModalProps } from "@mantine/modals"; import { type ReactNode, useEffect, useMemo, useState } from "react"; -import { useEntityQuery } from "ui/client"; +import { useEntityQuery } from "bknd/client"; import { type FileState, Media } from "ui/elements"; import { autoFormatString, datetimeStringLocal, formatNumber } from "core/utils"; import { twMerge } from "tailwind-merge"; diff --git a/app/src/ui/modules/auth/hooks/use-create-user-modal.ts b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts index f3d20ec..c0062b6 100644 --- a/app/src/ui/modules/auth/hooks/use-create-user-modal.ts +++ b/app/src/ui/modules/auth/hooks/use-create-user-modal.ts @@ -1,4 +1,4 @@ -import { useApi, useInvalidate } from "ui/client"; +import { useApi, useInvalidate } from "bknd/client"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { routes, useNavigate } from "ui/lib/routes"; import { bkndModals } from "ui/modals"; diff --git a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx index aa331bd..1a4300e 100644 --- a/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx +++ b/app/src/ui/modules/data/components/fields/EntityRelationalFormField.tsx @@ -4,7 +4,7 @@ import type { EntityData } from "bknd"; import type { RelationField } from "data/relations"; import { useEffect, useRef, useState } from "react"; import { TbEye } from "react-icons/tb"; -import { useEntityQuery } from "ui/client"; +import { useEntityQuery } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { Button } from "ui/components/buttons/Button"; import * as Formy from "ui/components/form/Formy"; diff --git a/app/src/ui/routes/auth/auth.index.tsx b/app/src/ui/routes/auth/auth.index.tsx index 9379606..e7c5bfc 100644 --- a/app/src/ui/routes/auth/auth.index.tsx +++ b/app/src/ui/routes/auth/auth.index.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { TbArrowRight, TbCircle, TbCircleCheckFilled, TbFingerprint } from "react-icons/tb"; -import { useApiQuery } from "ui/client"; +import { useApiQuery } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth"; import { ButtonLink, type ButtonLinkProps } from "ui/components/buttons/Button"; diff --git a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx index d24ac9b..477ca9e 100644 --- a/app/src/ui/routes/auth/auth.roles.edit.$role.tsx +++ b/app/src/ui/routes/auth/auth.roles.edit.$role.tsx @@ -35,7 +35,7 @@ import { SegmentedControl, Tooltip } from "@mantine/core"; import { Popover } from "ui/components/overlay/Popover"; import { cn } from "ui/lib/utils"; import { JsonViewer } from "ui/components/code/JsonViewer"; -import { mountOnce, useApiQuery } from "ui/client"; +import { mountOnce, useApiQuery } from "bknd/client"; import { CodePreview } from "ui/components/code/CodePreview"; import type { JsonError } from "json-schema-library"; import { Alert } from "ui/components/display/Alert"; diff --git a/app/src/ui/routes/data/data.$entity.$id.tsx b/app/src/ui/routes/data/data.$entity.$id.tsx index 30669e9..204c41b 100644 --- a/app/src/ui/routes/data/data.$entity.$id.tsx +++ b/app/src/ui/routes/data/data.$entity.$id.tsx @@ -3,7 +3,7 @@ import { ucFirst } from "bknd/utils"; import type { Entity, EntityData, EntityRelation } from "bknd"; import { Fragment, useState } from "react"; import { TbDots } from "react-icons/tb"; -import { useApiQuery, useEntityQuery } from "ui/client"; +import { useApiQuery, useEntityQuery } from "bknd/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { IconButton } from "ui/components/buttons/IconButton"; diff --git a/app/src/ui/routes/data/data.$entity.create.tsx b/app/src/ui/routes/data/data.$entity.create.tsx index 20b67ca..a1c3bfd 100644 --- a/app/src/ui/routes/data/data.$entity.create.tsx +++ b/app/src/ui/routes/data/data.$entity.create.tsx @@ -1,6 +1,6 @@ import type { EntityData } from "bknd"; import { useState } from "react"; -import { useEntityMutate } from "ui/client"; +import { useEntityMutate } from "bknd/client"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; import { Message } from "ui/components/display/Message"; diff --git a/app/src/ui/routes/data/data.$entity.index.tsx b/app/src/ui/routes/data/data.$entity.index.tsx index 5945f37..91cfbd9 100644 --- a/app/src/ui/routes/data/data.$entity.index.tsx +++ b/app/src/ui/routes/data/data.$entity.index.tsx @@ -2,7 +2,7 @@ import type { Entity } from "bknd"; import { repoQuery } from "data/server/query"; import { Fragment } from "react"; import { TbDots } from "react-icons/tb"; -import { useApiQuery } from "ui/client"; +import { useApiQuery } from "bknd/client"; import { useBknd } from "ui/client/bknd"; import { useBkndData } from "ui/client/schema/data/use-bknd-data"; import { Button } from "ui/components/buttons/Button"; diff --git a/app/src/ui/routes/data/forms/entity.fields.form.tsx b/app/src/ui/routes/data/forms/entity.fields.form.tsx index 4272f89..deba43c 100644 --- a/app/src/ui/routes/data/forms/entity.fields.form.tsx +++ b/app/src/ui/routes/data/forms/entity.fields.form.tsx @@ -5,12 +5,12 @@ import { ucFirstAllSnakeToPascalWithSpaces, s, stringIdentifier, + pickKeys, } from "bknd/utils"; import { type TAppDataEntityFields, fieldsSchemaObject as originalFieldsSchemaObject, } from "data/data-schema"; -import { omit } from "lodash-es"; import { forwardRef, memo, useEffect, useImperativeHandle } from "react"; import { type FieldArrayWithId, type UseFormReturn, useFieldArray, useForm } from "react-hook-form"; import { TbGripVertical, TbSettings, TbTrash } from "react-icons/tb"; @@ -317,7 +317,6 @@ function EntityField({ const name = watch(`fields.${index}.name`); const { active, toggle } = useRoutePathState(routePattern ?? "", name); const fieldSpec = fieldSpecs.find((s) => s.type === type)!; - const specificData = omit(field.field.config, commonProps); const disabled = fieldSpec.disabled || []; const hidden = fieldSpec.hidden || []; const dragDisabled = index === 0; @@ -476,7 +475,7 @@ function EntityField({ field={field} onChange={(value) => { setValue(`${prefix}.config`, { - ...getValues([`fields.${index}.config`])[0], + ...pickKeys(getValues([`${prefix}.config`])[0], commonProps), ...value, }); }} @@ -520,7 +519,7 @@ const SpecificForm = ({ readonly?: boolean; }) => { const type = field.field.type; - const specificData = omit(field.field.config, commonProps); + const specificData = omitKeys(field.field.config ?? {}, commonProps); return (
- +
); diff --git a/app/src/ui/routes/root.tsx b/app/src/ui/routes/root.tsx index 184c903..4438362 100644 --- a/app/src/ui/routes/root.tsx +++ b/app/src/ui/routes/root.tsx @@ -1,6 +1,6 @@ import { IconHome } from "@tabler/icons-react"; import { useEffect } from "react"; -import { useAuth } from "ui/client"; +import { useAuth } from "bknd/client"; import { useEffectOnce } from "ui/hooks/use-effect"; import { Empty } from "../components/display/Empty"; import { useBrowserTitle } from "../hooks/use-browser-title"; diff --git a/app/src/ui/routes/test/tests/swr-and-api.tsx b/app/src/ui/routes/test/tests/swr-and-api.tsx index 0f20428..92b0b10 100644 --- a/app/src/ui/routes/test/tests/swr-and-api.tsx +++ b/app/src/ui/routes/test/tests/swr-and-api.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useApi, useApiQuery } from "ui/client"; +import { useApi, useApiQuery } from "bknd/client"; import { Scrollable } from "ui/layouts/AppShell/AppShell"; function Bla() { diff --git a/app/tsconfig.json b/app/tsconfig.json index 10260b4..d6060d9 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -35,7 +35,8 @@ "bknd/adapter": ["./src/adapter/index.ts"], "bknd/adapter/*": ["./src/adapter/*/index.ts"], "bknd/client": ["./src/ui/client/index.ts"], - "bknd/modes": ["./src/modes/index.ts"] + "bknd/modes": ["./src/modes/index.ts"], + "bknd/elements": ["./src/ui/elements/index.ts"] } }, "include": [ diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 255fe26..105b014 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -70,8 +70,9 @@ switch (dbType) { if (example) { const name = slugify(example); configPath = `.configs/${slugify(example)}.wrangler.json`; - const exists = await readFile(configPath, "utf-8"); - if (!exists) { + try { + await readFile(configPath, "utf-8"); + } catch (_e) { wranglerConfig.name = name; wranglerConfig.d1_databases[0]!.database_name = name; wranglerConfig.d1_databases[0]!.database_id = crypto.randomUUID(); diff --git a/biome.json b/biome.json index 28818cb..f1d462b 100644 --- a/biome.json +++ b/biome.json @@ -1,14 +1,14 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", - "organizeImports": { - "enabled": true - }, + "$schema": "https://biomejs.dev/schemas/2.3.3/schema.json", + "assist": { "actions": { "source": { "organizeImports": "off" } } }, "vcs": { "defaultBranch": "main" }, "formatter": { "enabled": true, - "indentStyle": "space" + "indentStyle": "space", + "formatWithErrors": true, + "includes": ["**", "!!**/package.json"] }, "javascript": { "formatter": { @@ -20,6 +20,9 @@ "css": { "formatter": { "indentWidth": 3 + }, + "parser": { + "tailwindDirectives": true } }, "json": { @@ -30,32 +33,37 @@ } }, "files": { - "ignore": [ - "**/node_modules/**", - "node_modules/**", - "**/.cache/**", - "**/.wrangler/**", - "**/build/**", - "**/dist/**", - "**/data.sqld/**", - "data.sqld/**", - "public/**", - ".history/**" + "includes": [ + "**", + "!!**/.tsup", + "!!**/node_modules", + "!!**/.cache", + "!!**/.wrangler", + "!!**/build", + "!!**/dist", + "!!**/data.sqld", + "!!**/data.sqld", + "!!**/public", + "!!**/.history" ] }, "linter": { "enabled": true, - "ignore": ["**/*.spec.ts"], + "includes": ["**", "!!**/vitest.config.ts", "!!app/build.ts"], "rules": { "recommended": true, - "a11y": { - "all": false - }, + "a11y": {}, "correctness": { "useExhaustiveDependencies": "off", "noUnreachable": "warn", "noChildrenProp": "off", - "noSwitchDeclarations": "warn" + "noSwitchDeclarations": "warn", + "noUnusedVariables": { + "options": { + "ignoreRestSiblings": true + }, + "level": "warn" + } }, "complexity": { "noUselessFragments": "warn", @@ -70,7 +78,11 @@ "noArrayIndexKey": "off", "noImplicitAnyLet": "warn", "noConfusingVoidType": "off", - "noConsoleLog": "warn" + "noConsole": { + "level": "warn", + "options": { "allow": ["error", "info"] } + }, + "noTsIgnore": "off" }, "security": { "noDangerouslySetInnerHtml": "off" diff --git a/bun.lock b/bun.lock index 06de3c7..1a3d426 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "bknd", @@ -3864,6 +3865,8 @@ "@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "@bknd/plasmic/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + "@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="], "@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="], @@ -4756,6 +4759,8 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="], + "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + "@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="], "@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="], @@ -5252,6 +5257,8 @@ "@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + "@bknd/plasmic/@types/bun/bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="], "@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="], @@ -5472,6 +5479,8 @@ "wrangler/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "@bknd/plasmic/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], diff --git a/bunfig.toml b/bunfig.toml index 9268116..22c0415 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,2 @@ [install] -#linker = "hoisted" \ No newline at end of file +linker = "isolated" \ No newline at end of file diff --git a/docker/debug/Dockerfile.minimal b/docker/debug/Dockerfile.minimal new file mode 100644 index 0000000..07868fe --- /dev/null +++ b/docker/debug/Dockerfile.minimal @@ -0,0 +1,14 @@ +FROM alpine:latest + +# Install Node.js and npm +RUN apk add --no-cache nodejs npm + +# Set working directory +WORKDIR /app + +# Create package.json with type: module +RUN echo '{"type":"module"}' > package.json + +# Keep container running (can be overridden) +CMD ["sh"] + diff --git a/docker/debug/run-minimal.sh b/docker/debug/run-minimal.sh new file mode 100755 index 0000000..0a5ca28 --- /dev/null +++ b/docker/debug/run-minimal.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Build the minimal Alpine image with Node.js +docker build -f Dockerfile.minimal -t bknd-minimal . + +# Run the container with the whole app/src directory mapped +docker run -it --rm \ + -v "$(pwd)/../app:/app/app" \ + -w /app \ + -p 1337:1337 \ + bknd-minimal + diff --git a/docs/content/docs/(documentation)/extending/plugins.mdx b/docs/content/docs/(documentation)/extending/plugins.mdx index 850629d..df20327 100644 --- a/docs/content/docs/(documentation)/extending/plugins.mdx +++ b/docs/content/docs/(documentation)/extending/plugins.mdx @@ -261,3 +261,77 @@ export default { ``` +### `emailOTP` + + + Make sure to setup proper permissions to restrict reading from the OTP entity. Also, this plugin requires the `email` driver to be registered. + + + +A plugin that adds email OTP functionality to your app. It will add two endpoints to your app: +- `POST /api/auth/otp/login` to login a user with an OTP code +- `POST /api/auth/otp/register` to register a user with an OTP code + +Both endpoints accept a JSON body with `email` (required) and `code` (optional). If `code` is provided, the OTP code will be validated and the user will be logged in or registered. If `code` is not provided, a new OTP code will be generated and sent to the user's email. + +For example, to login an existing user with an OTP code, two requests are needed. The first one only with the email to generate and send the OTP code, and the second to send the users' email along with the OTP code. The last request will authenticate the user. + +```http title="Generate OTP code to login" +POST /api/auth/otp/login +Content-Type: application/json + +{ + "email": "test@example.com" +} +``` + +If the user exists, an email will be sent with the OTP code, and the response will be a `201 Created`. + +```http title="Login with OTP code" +POST /api/auth/otp/login +Content-Type: application/json + +{ + "email": "test@example.com", + "code": "123456" +} +``` + +If the code is valid, the user will be authenticated by sending a `Set-Cookie` header and a body property `token` with the JWT token (equally to the login endpoint). + + +```typescript title="bknd.config.ts" +import { emailOTP } from "bknd/plugins"; +import { resendEmail } from "bknd"; + +export default { + options: { + drivers: { + // an email driver is required + email: resendEmail({ /* ... */}), + }, + plugins: [ + // all options are optional + emailOTP({ + // the base path for the API endpoints + apiBasePath: "/api/auth/otp", + // the TTL for the OTP tokens in seconds + ttl: 600, + // the name of the OTP entity + entity: "users_otp", + // customize the email content + generateEmail: (otp) => ({ + subject: "OTP Code", + body: `Your OTP code is: ${otp.code}`, + }), + // customize the code generation + generateCode: (user) => { + return Math.floor(100000 + Math.random() * 900000).toString(); + }, + }) + ], + }, +} satisfies BkndConfig; +``` + + diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx index 064a422..be96c3a 100644 --- a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx +++ b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx @@ -40,12 +40,6 @@ bun add bknd - - The guide below assumes you're using Astro v4. We've experienced issues with - Astro DB using v5, see [this - issue](https://github.com/withastro/astro/issues/12474). - - For the Astro integration to work, you also need to [add the react integration](https://docs.astro.build/en/guides/integrations-guide/react/): ```bash @@ -159,7 +153,7 @@ Create a new catch-all route at `src/pages/admin/[...admin].astro`: import { Admin } from "bknd/ui"; import "bknd/dist/styles.css"; -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../../../bknd.ts"; // /src/bknd.ts const api = await getApi(Astro, { mode: "dynamic" }); const user = api.getUser(); diff --git a/docs/content/docs/(documentation)/usage/introduction.mdx b/docs/content/docs/(documentation)/usage/introduction.mdx index adf6810..c290a12 100644 --- a/docs/content/docs/(documentation)/usage/introduction.mdx +++ b/docs/content/docs/(documentation)/usage/introduction.mdx @@ -213,9 +213,9 @@ To use it, you have to wrap your configuration in a mode helper, e.g. for `code` import { code, type CodeMode } from "bknd/modes"; import { type BunBkndConfig, writer } from "bknd/adapter/bun"; -const config = { +export default code({ // some normal bun bknd config - connection: { url: "file:test.db" }, + connection: { url: "file:data.db" }, // ... // a writer is required, to sync the types writer, @@ -227,9 +227,7 @@ const config = { force: true, drop: true, } -} satisfies CodeMode; - -export default code(config); +}); ``` Similarily, for `hybrid` mode: @@ -238,9 +236,9 @@ Similarily, for `hybrid` mode: import { hybrid, type HybridMode } from "bknd/modes"; import { type BunBkndConfig, writer, reader } from "bknd/adapter/bun"; -const config = { +export default hybrid({ // some normal bun bknd config - connection: { url: "file:test.db" }, + connection: { url: "file:data.db" }, // ... // reader/writer are required, to sync the types and config writer, @@ -262,7 +260,5 @@ const config = { force: true, drop: true, }, -} satisfies HybridMode; - -export default hybrid(config); +}); ``` \ No newline at end of file diff --git a/examples/.gitignore b/examples/.gitignore index d305846..6fec887 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -2,4 +2,5 @@ */bun.lock */deno.lock */node_modules -*/*.db \ No newline at end of file +*/*.db +*/worker-configuration.d.ts \ No newline at end of file diff --git a/examples/cloudflare-vite-code/.env.local b/examples/cloudflare-vite-code/.env.local new file mode 100644 index 0000000..ab1e1c5 --- /dev/null +++ b/examples/cloudflare-vite-code/.env.local @@ -0,0 +1 @@ +JWT_SECRET=secret \ No newline at end of file diff --git a/examples/cloudflare-vite-code/README.md b/examples/cloudflare-vite-code/README.md new file mode 100644 index 0000000..c54626f --- /dev/null +++ b/examples/cloudflare-vite-code/README.md @@ -0,0 +1,348 @@ +# bknd starter: Cloudflare Vite Code-Only +A fullstack React + Vite application with bknd integration, showcasing **code-only mode** and Cloudflare Workers deployment. + +## Key Features + +This example demonstrates a minimal, code-first approach to building with bknd: + +### 💻 Code-Only Mode +Define your entire backend **programmatically** using a Drizzle-like API. Your data structure, authentication, and configuration live directly in code with zero build-time tooling required. Perfect for developers who prefer traditional code-first workflows. + +### 🎯 Minimal Boilerplate +Unlike the hybrid mode template, this example uses **no automatic type generation**, **no filesystem plugins**, and **no auto-synced configuration files**. This simulates a typical development environment where you manage types generation manually. If you prefer automatic type generation, you can easily add it using the [CLI](https://docs.bknd.io/usage/cli#generating-types-types) or [Vite plugin](https://docs.bknd.io/extending/plugins#synctypes). + +### ⚡ Split Configuration Pattern +- **`config.ts`**: Main configuration that defines your schema and can be safely imported in your worker +- **`bknd.config.ts`**: Wraps the configuration with `withPlatformProxy` for CLI usage with Cloudflare bindings (should NOT be imported in your worker) + +This pattern prevents bundling `wrangler` into your worker while still allowing CLI access to Cloudflare resources. + +## Project Structure + +Inside of your project, you'll see the following folders and files: + +```text +/ +├── src/ +│ ├── app/ # React frontend application +│ │ ├── App.tsx +│ │ ├── routes/ +│ │ │ ├── admin.tsx # bknd Admin UI route +│ │ │ └── home.tsx # Example frontend route +│ │ └── main.tsx +│ └── worker/ +│ └── index.ts # Cloudflare Worker entry +├── config.ts # bknd configuration with schema definition +├── bknd.config.ts # CLI configuration with platform proxy +├── seed.ts # Optional: seed data for development +├── vite.config.ts # Standard Vite config (no bknd plugins) +├── package.json +└── wrangler.json # Cloudflare Workers configuration +``` + +## Cloudflare Resources + +- **D1:** `wrangler.json` declares a `DB` binding. In production, replace `database_id` with your own (`wrangler d1 create `). +- **R2:** Optional `BUCKET` binding is pre-configured to show how to add additional services. +- **Environment awareness:** `ENVIRONMENT` variable determines whether to sync the database schema automatically (development only). +- **Static assets:** The Assets binding points to `dist/client`. Run `npm run build` before `wrangler deploy` to upload the client bundle alongside the worker. + +## Admin UI & Frontend + +- `/admin` mounts `` from `bknd/ui` with `withProvider={{ user }}` so it respects the authenticated user returned by `useAuth`. +- `/` showcases `useEntityQuery("todos")`, mutation helpers, and authentication state — demonstrating how manually declared types flow into the React code. + + +## Configuration Files + +### `config.ts` +The main configuration file that uses the `code()` mode helper: + +```typescript +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { code } from "bknd/modes"; +import { boolean, em, entity, text } from "bknd"; + +// define your schema using a Drizzle-like API +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema for type completion (optional) +// alternatively, you can use the CLI to auto-generate types +type Database = (typeof schema)["DB"]; +declare module "bknd" { + interface DB extends Database {} +} + +export default code({ + app: (env) => ({ + config: { + // convert schema to JSON format + data: schema.toJSON(), + auth: { + enabled: true, + jwt: { + // secrets are directly passed to the config + secret: env.JWT_SECRET, + issuer: "cloudflare-vite-code-example", + }, + }, + }, + // disable the built-in admin controller (we render our own app) + adminOptions: false, + // determines whether the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + }), +}); +``` + +Key differences from hybrid mode: +- **No auto-generated files**: No `bknd-config.json`, `bknd-types.d.ts`, or `.env.example` +- **Manual type declaration**: Types are declared inline using `declare module "bknd"` +- **Direct secret access**: Secrets come directly from `env` parameters +- **Simpler setup**: No filesystem plugins or readers/writers needed + +If you prefer automatic type generation, you can add it later using: +- **CLI**: `npm run bknd -- types` (requires adding `typesFilePath` to config) +- **Plugin**: Import `syncTypes` plugin and configure it in your app + +### `bknd.config.ts` +Wraps the configuration for CLI usage with Cloudflare bindings: + +```typescript +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); +``` + +**Important**: Don't import this file in your worker, as it would bundle `wrangler` into your production code. This file is only used by the bknd CLI. + +### `vite.config.ts` +Standard Vite configuration without bknd-specific plugins: + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss(), cloudflare()], +}); +``` + +## Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +|:-------------------|:----------------------------------------------------------| +| `npm install` | Installs dependencies, generates types, and seeds database| +| `npm run dev` | Starts local dev server with Vite at `localhost:5173` | +| `npm run build` | Builds the application for production | +| `npm run preview` | Builds and previews the production build locally | +| `npm run deploy` | Builds, syncs the schema and deploys to Cloudflare Workers| +| `npm run bknd` | Runs bknd CLI commands | +| `npm run bknd:seed`| Seeds the database with example data | +| `npm run cf:types` | Generates Cloudflare Worker types from `wrangler.json` | +| `npm run check` | Type checks and does a dry-run deployment | + +## Development Workflow + +1. **Install dependencies:** + ```sh + npm install + ``` + This will install dependencies, generate Cloudflare types, and seed the database. + +2. **Start development server:** + ```sh + npm run dev + ``` + +3. **Define your schema in code** (`config.ts`): + ```typescript + const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), + }); + ``` + +4. **Manually declare types** (optional, but recommended for IDE support): + ```typescript + type Database = (typeof schema)["DB"]; + declare module "bknd" { + interface DB extends Database {} + } + ``` + +5. **Use the Admin UI** at `http://localhost:5173/admin` to: + - View and manage your data + - Monitor authentication + - Access database tools + + Note: In code mode, you cannot edit the schema through the UI. All schema changes must be done in `config.ts`. + +6. **Sync schema changes** to your database: + ```sh + # Local development (happens automatically on startup) + npm run dev + + # Production database (safe operations only) + CLOUDFLARE_ENV=production npm run bknd -- sync --force + ``` + +## Before You Deploy + +### 1. Create a D1 Database + +Create a database in your Cloudflare account: + +```sh +npx wrangler d1 create my-database +``` + +Update `wrangler.json` with your database ID: +```json +{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-database", + "database_id": "your-database-id-here" + } + ] +} +``` + +### 2. Set Required Secrets + +Set your secrets in Cloudflare Workers: + +```sh +# JWT secret (required for authentication) +npx wrangler secret put JWT_SECRET +``` + +You can generate a secure secret using: +```sh +# Using openssl +openssl rand -base64 64 +``` + +## Deployment + +Deploy to Cloudflare Workers: + +```sh +npm run deploy +``` + +This will: +1. Set `ENVIRONMENT=production` to prevent automatic schema syncing +2. Build the Vite application +3. Sync the database schema (safe operations only) +4. Deploy to Cloudflare Workers using Wrangler + +In production, bknd will: +- Use the configuration defined in `config.ts` +- Skip config validation for better performance +- Expect secrets to be provided via environment variables + +## How Code Mode Works + +1. **Define Schema:** Create entities and fields using the Drizzle-like API in `config.ts` +2. **Convert to JSON:** Use `schema.toJSON()` to convert your schema to bknd's configuration format +3. **Manual Types:** Optionally declare types inline for IDE support and type safety +4. **Deploy:** Same configuration runs in both development and production + +### Code Mode vs Hybrid Mode + +| Feature | Code Mode | Hybrid Mode | +|---------|-----------|-------------| +| Schema Definition | Code-only (`em`, `entity`, `text`) | Visual UI in dev, code in prod | +| Configuration Files | None (all in code) | Auto-generated `bknd-config.json` | +| Type Generation | Manual or opt-in | Automatic | +| Setup Complexity | Minimal | Requires plugins & filesystem access | +| Use Case | Traditional code-first workflows | Rapid prototyping, visual development | + +## Type Generation (Optional) + +This example intentionally **does not use automatic type generation** to simulate a typical development environment where types are managed manually. This approach: +- Reduces build complexity +- Eliminates dependency on build-time tooling +- Works in any environment without special plugins + +However, if you prefer automatic type generation, you can easily add it: + +### Option 1: Using the Vite Plugin and `code` helper presets +Add `typesFilePath` to your config: + +```typescript +export default code({ + typesFilePath: "./bknd-types.d.ts", + // ... rest of config +}); +``` + +For Cloudflare Workers, you'll need the `devFsVitePlugin`: +```typescript +// vite.config.ts +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + // ... + devFsVitePlugin({ configFile: "config.ts" }) + ], +}); +``` + +Finally, add the generated types to your `tsconfig.json`: +```json +{ + "compilerOptions": { + "types": ["./bknd-types.d.ts"] + } +} +``` + +This provides filesystem access for auto-syncing types despite Cloudflare's `unenv` restrictions. + +### Option 2: Using the CLI + +You may also use the CLI to generate types: + +```sh +npx bknd types --outfile ./bknd-types.d.ts +``` + +## Database Seeding + +Unlike UI-only and hybrid modes where bknd can automatically detect an empty database (by attempting to fetch the configuration. A "table not found" error indicates a fresh database), **code mode requires manual seeding**. This is because in code mode, the configuration is always provided from code, so bknd can't determine if the database is empty without additional queries, which would impact performance. + +This example includes a [`seed.ts`](./seed.ts) file that you can run manually. For Cloudflare, it uses `bknd.config.ts` (with `withPlatformProxy`) to access Cloudflare resources like D1 during CLI execution: + +```sh +npm run bknd:seed +``` + +The seed script manually checks if the database is empty before inserting data. See the [seed.ts](./seed.ts) file for implementation details. + +## Want to Learn More? + +- [Cloudflare Integration Documentation](https://docs.bknd.io/integration/cloudflare) +- [Code Mode Guide](https://docs.bknd.io/usage/introduction#code-only-mode) +- [Mode Helpers Documentation](https://docs.bknd.io/usage/introduction#mode-helpers) +- [Data Structure & Schema API](https://docs.bknd.io/usage/database#data-structure) +- [Discord Community](https://discord.gg/952SFk8Tb8) + diff --git a/examples/cloudflare-vite-code/bknd.config.ts b/examples/cloudflare-vite-code/bknd.config.ts new file mode 100644 index 0000000..8129c0e --- /dev/null +++ b/examples/cloudflare-vite-code/bknd.config.ts @@ -0,0 +1,15 @@ +/** + * This file gets automatically picked up by the bknd CLI. Since we're using cloudflare, + * we want to use cloudflare bindings (such as the database). To do this, we need to wrap + * the configuration with the `withPlatformProxy` helper function. + * + * Don't import this file directly in your app, otherwise "wrangler" will be bundled with your worker. + * That's why we split the configuration into two files: `bknd.config.ts` and `config.ts`. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); diff --git a/examples/cloudflare-vite-code/config.ts b/examples/cloudflare-vite-code/config.ts new file mode 100644 index 0000000..a0f0c8c --- /dev/null +++ b/examples/cloudflare-vite-code/config.ts @@ -0,0 +1,43 @@ +/// + +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { code } from "bknd/modes"; +import { boolean, em, entity, text } from "bknd"; + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +// alternatively, you can use the CLI to generate types +// learn more at https://docs.bknd.io/usage/cli/#generating-types-types +type Database = (typeof schema)["DB"]; +declare module "bknd" { + interface DB extends Database {} +} + +export default code({ + app: (env) => ({ + config: { + data: schema.toJSON(), + auth: { + enabled: true, + jwt: { + // unlike hybrid mode, secrets are directly passed to the config + secret: env.JWT_SECRET, + issuer: "cloudflare-vite-code-example", + }, + }, + }, + // we need to disable the admin controller using the vite plugin, since we want to render our own app + adminOptions: false, + // this is important to determine whether the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + + // note: usually you would use `options.seed` to seed the database, but since we're using code mode, + // we don't know when the db is empty. So we need to create a separate seed function, see `seed.ts`. + }), +}); diff --git a/examples/cloudflare-vite-code/index.html b/examples/cloudflare-vite-code/index.html new file mode 100644 index 0000000..5b7fd70 --- /dev/null +++ b/examples/cloudflare-vite-code/index.html @@ -0,0 +1,14 @@ + + + + + + + bknd + Vite + Cloudflare + React + TS + + + +
+ + + diff --git a/examples/cloudflare-vite-code/package.json b/examples/cloudflare-vite-code/package.json new file mode 100644 index 0000000..1c0a53c --- /dev/null +++ b/examples/cloudflare-vite-code/package.json @@ -0,0 +1,36 @@ +{ + "name": "cloudflare-vite-fullstack-bknd-code", + "description": "A template for building a React application with Vite, Cloudflare Workers, and bknd", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "cf:types": "wrangler types", + "bknd": "node --experimental-strip-types node_modules/.bin/bknd", + "bknd:seed": "NODE_NO_WARNINGS=1 node --experimental-strip-types seed.ts", + "check": "tsc && vite build && wrangler deploy --dry-run", + "deploy": "CLOUDFLARE_ENV=production vite build && CLOUDFLARE_ENV=production npm run bknd -- sync --force && wrangler deploy", + "dev": "vite", + "preview": "npm run build && vite preview", + "postinstall": "npm run cf:types && npm run bknd:seed" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.17", + "bknd": "file:../../app", + "hono": "4.10.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.17", + "wouter": "^3.7.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "typescript": "5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } +} diff --git a/examples/cloudflare-vite-code/public/vite.svg b/examples/cloudflare-vite-code/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-code/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/seed.ts b/examples/cloudflare-vite-code/seed.ts new file mode 100644 index 0000000..e548e69 --- /dev/null +++ b/examples/cloudflare-vite-code/seed.ts @@ -0,0 +1,28 @@ +/// + +import { createFrameworkApp } from "bknd/adapter"; +import config from "./bknd.config.ts"; + +const app = await createFrameworkApp(config, {}); + +const { + data: { count: usersCount }, +} = await app.em.repo("users").count(); +const { + data: { count: todosCount }, +} = await app.em.repo("todos").count(); + +// only run if the database is empty +if (usersCount === 0 && todosCount === 0) { + await app.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + await app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); +} + +process.exit(0); diff --git a/examples/cloudflare-vite-code/src/app/App.tsx b/examples/cloudflare-vite-code/src/app/App.tsx new file mode 100644 index 0000000..9aa6a5f --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/App.tsx @@ -0,0 +1,39 @@ +import { Router, Switch, Route } from "wouter"; +import Home from "./routes/home.tsx"; +import { lazy, Suspense, useEffect, useState } from "react"; +const Admin = lazy(() => import("./routes/admin.tsx")); +import { useAuth } from "bknd/client"; + +export default function App() { + const auth = useAuth(); + const [verified, setVerified] = useState(false); + + useEffect(() => { + auth.verify().then(() => setVerified(true)); + }, []); + + if (!verified) return null; + + return ( + + + + + + + + + +
+ 404 +
+
+
+
+ ); +} diff --git a/examples/cloudflare-vite-code/src/app/assets/bknd.svg b/examples/cloudflare-vite-code/src/app/assets/bknd.svg new file mode 100644 index 0000000..182ef92 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/bknd.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/assets/cloudflare.svg b/examples/cloudflare-vite-code/src/app/assets/cloudflare.svg new file mode 100644 index 0000000..3bb7ac0 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/cloudflare.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/assets/react.svg b/examples/cloudflare-vite-code/src/app/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/assets/vite.svg b/examples/cloudflare-vite-code/src/app/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/index.css b/examples/cloudflare-vite-code/src/app/index.css new file mode 100644 index 0000000..4e0bdd8 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/index.css @@ -0,0 +1,28 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +@theme { + --color-background: var(--background); + --color-foreground: var(--foreground); +} + +body { + @apply bg-background text-foreground flex; + font-family: Arial, Helvetica, sans-serif; +} + +#root { + width: 100%; + min-height: 100dvh; +} diff --git a/examples/cloudflare-vite-code/src/app/main.tsx b/examples/cloudflare-vite-code/src/app/main.tsx new file mode 100644 index 0000000..8d55eb2 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { ClientProvider } from "bknd/client"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/examples/cloudflare-vite-code/src/app/routes/admin.tsx b/examples/cloudflare-vite-code/src/app/routes/admin.tsx new file mode 100644 index 0000000..93a4d9b --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/routes/admin.tsx @@ -0,0 +1,8 @@ +import { Admin, type BkndAdminProps } from "bknd/ui"; +import "bknd/dist/styles.css"; +import { useAuth } from "bknd/client"; + +export default function AdminPage(props: BkndAdminProps) { + const auth = useAuth(); + return ; +} diff --git a/examples/cloudflare-vite-code/src/app/routes/home.tsx b/examples/cloudflare-vite-code/src/app/routes/home.tsx new file mode 100644 index 0000000..5d2197a --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/routes/home.tsx @@ -0,0 +1,94 @@ +import { useAuth, useEntityQuery } from "bknd/client"; +import bkndLogo from "../assets/bknd.svg"; +import cloudflareLogo from "../assets/cloudflare.svg"; +import viteLogo from "../assets/vite.svg"; + +export default function Home() { + const auth = useAuth(); + + const limit = 5; + const { data: todos, ...$q } = useEntityQuery("todos", undefined, { + limit, + sort: "-id", + }); + + return ( +
+
+ bknd +
&
+
+ cloudflare +
+
+ vite +
+
+ +
+

+ What's next? +

+
+
+ {todos && + [...todos].reverse().map((todo) => ( +
+
+ { + await $q.update({ done: !todo.done }, todo.id); + }} + /> +
{todo.title}
+
+ +
+ ))} +
+
t.id).join()} + action={async (formData: FormData) => { + const title = formData.get("title") as string; + await $q.create({ title }); + }} + > + + +
+
+
+ +
+ Go to Admin. ➝ +
+ {auth.user ? ( +

+ Authenticated as {auth.user.email} +

+ ) : ( + Login + )} +
+
+
+ ); +} diff --git a/examples/cloudflare-vite-code/src/app/vite-env.d.ts b/examples/cloudflare-vite-code/src/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cloudflare-vite-code/src/worker/index.ts b/examples/cloudflare-vite-code/src/worker/index.ts new file mode 100644 index 0000000..7f22fcf --- /dev/null +++ b/examples/cloudflare-vite-code/src/worker/index.ts @@ -0,0 +1,7 @@ +import { serve } from "bknd/adapter/cloudflare"; +import config from "../../config.ts"; + +export default serve(config, () => ({ + // since bknd is running code-only, we can use a pre-initialized app instance if available + warm: true, +})); diff --git a/examples/cloudflare-vite-code/tsconfig.app.json b/examples/cloudflare-vite-code/tsconfig.app.json new file mode 100644 index 0000000..643d6aa --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/app", "./config.ts"] +} diff --git a/examples/cloudflare-vite-code/tsconfig.json b/examples/cloudflare-vite-code/tsconfig.json new file mode 100644 index 0000000..c7155af --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ], + "compilerOptions": { + "types": ["./worker-configuration.d.ts", "node"] + } +} diff --git a/examples/cloudflare-vite-code/tsconfig.node.json b/examples/cloudflare-vite-code/tsconfig.node.json new file mode 100644 index 0000000..50130b3 --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite-code/tsconfig.worker.json b/examples/cloudflare-vite-code/tsconfig.worker.json new file mode 100644 index 0000000..8a7758a --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["vite/client", "./worker-configuration.d.ts"] + }, + "include": ["src/worker", "src/config.ts"] +} diff --git a/examples/cloudflare-vite-code/vite.config.ts b/examples/cloudflare-vite-code/vite.config.ts new file mode 100644 index 0000000..7704b9f --- /dev/null +++ b/examples/cloudflare-vite-code/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss(), cloudflare()], + build: { + minify: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + }, +}); diff --git a/examples/cloudflare-vite-code/wrangler.json b/examples/cloudflare-vite-code/wrangler.json new file mode 100644 index 0000000..d47fedd --- /dev/null +++ b/examples/cloudflare-vite-code/wrangler.json @@ -0,0 +1,47 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-fullstack-bknd", + "main": "./src/worker/index.ts", + "compatibility_date": "2025-10-08", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "assets": { + "binding": "ASSETS", + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api*", "!/assets/*"] + }, + "vars": { + "ENVIRONMENT": "development" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ], + "env": { + "production": { + "vars": { + "ENVIRONMENT": "production" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ] + } + } +} diff --git a/examples/cloudflare-vite-hybrid/.env.example b/examples/cloudflare-vite-hybrid/.env.example new file mode 100644 index 0000000..ab058b1 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/.env.example @@ -0,0 +1 @@ +auth.jwt.secret=secret \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/README.md b/examples/cloudflare-vite-hybrid/README.md new file mode 100644 index 0000000..5f19c07 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/README.md @@ -0,0 +1,249 @@ +# bknd starter: Cloudflare Vite Hybrid +A fullstack React + Vite application with bknd integration, showcasing **hybrid mode** and Cloudflare Workers deployment. + +## Key Features + +This example demonstrates several advanced bknd features: + +### 🔄 Hybrid Mode +Configure your backend **visually in development** using the Admin UI, then automatically switch to **code-only mode in production** for maximum performance. Changes made in the Admin UI are automatically synced to `bknd-config.json` and type definitions are generated in `bknd-types.d.ts`. + +### 📁 Filesystem Access with Vite Plugin +Cloudflare's Vite plugin uses `unenv` which disables Node.js APIs like `fs`. This example uses bknd's `devFsVitePlugin` and `devFsWrite` to provide filesystem access during development, enabling automatic syncing of types and configuration. + +### ⚡ Split Configuration Pattern +- **`config.ts`**: Shared configuration that can be safely imported in your worker +- **`bknd.config.ts`**: Wraps the configuration with `withPlatformProxy` for CLI usage with Cloudflare bindings (should NOT be imported in your worker) + +This pattern prevents bundling `wrangler` into your worker while still allowing CLI access to Cloudflare resources. + +## Project Structure + +Inside of your project, you'll see the following folders and files: + +```text +/ +├── src/ +│ ├── app/ # React frontend application +│ │ ├── App.tsx +│ │ ├── routes/ +│ │ │ ├── admin.tsx # bknd Admin UI route +│ │ │ └── home.tsx # Example frontend route +│ │ └── main.tsx +│ └── worker/ +│ └── index.ts # Cloudflare Worker entry +├── config.ts # Shared bknd configuration (hybrid mode) +├── bknd.config.ts # CLI configuration with platform proxy +├── bknd-config.json # Auto-generated production config +├── bknd-types.d.ts # Auto-generated TypeScript types +├── .env.example # Auto-generated secrets template +├── vite.config.ts # Includes devFsVitePlugin +├── package.json +└── wrangler.json # Cloudflare Workers configuration +``` + +## Cloudflare resources + +- **D1:** `wrangler.json` declares a `DB` binding. In production, replace `database_id` with your own (`wrangler d1 create `). +- **R2:** Optional `BUCKET` binding is pre-configured to show how to add additional services. +- **Environment awareness:** `ENVIRONMENT` switch toggles hybrid behavior: production makes the database read-only, while development keeps `mode: "db"` and auto-syncs schema. +- **Static assets:** The Assets binding points to `dist/client`. Run `npm run build` before `wrangler deploy` to upload the client bundle alongside the worker. + +## Admin UI & frontend + +- `/admin` mounts `` from `bknd/ui` with `withProvider={{ user }}` so it respects the authenticated user returned by `useAuth`. +- `/` showcases `useEntityQuery("todos")`, mutation helpers, and authentication state — demonstrating how the generated client types (`bknd-types.d.ts`) flow into the React code. + + +## Configuration Files + +### `config.ts` +The main configuration file that uses the `hybrid()` mode helper: + + - Loads the generated config via an ESM `reader` (importing `./bknd-config.json`). + - Uses `devFsWrite` as the `writer` so the CLI/plugin can persist files even though Node's `fs` API is unavailable in Miniflare. + - Sets `typesFilePath`, `configFilePath`, and `syncSecrets` (writes `.env.example`) so config, types, and secret placeholders stay aligned. + - Seeds example data/users in `options.seed` when the database is empty. + - Disables the built-in admin controller because the React app renders `/admin` via `bknd/ui`. + + +```typescript +import { hybrid } from "bknd/modes"; +import { devFsWrite, type CloudflareBkndConfig } from "bknd/adapter/cloudflare"; + +export default hybrid({ + // Special reader for Cloudflare Workers (no Node.js fs) + reader: async () => (await import("./bknd-config.json")).default, + // devFsWrite enables file writing via Vite plugin + writer: devFsWrite, + // Auto-sync these files in development + typesFilePath: "./bknd-types.d.ts", + configFilePath: "./bknd-config.json", + syncSecrets: { + enabled: true, + outFile: ".env.example", + format: "env", + }, + app: (env) => ({ + adminOptions: false, // Disabled - we render React app instead + isProduction: env.ENVIRONMENT === "production", + secrets: env, + // ... your configuration + }), +}); +``` + +### `bknd.config.ts` +Wraps the configuration for CLI usage with Cloudflare bindings: + +```typescript +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config); +``` + +### `vite.config.ts` +Includes the `devFsVitePlugin` for filesystem access: + +```typescript +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + // ... + devFsVitePlugin({ configFile: "config.ts" }), + cloudflare(), + ], +}); +``` + +## Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +|:-------------------|:----------------------------------------------------------| +| `npm install` | Installs dependencies and generates wrangler types | +| `npm run dev` | Starts local dev server with Vite at `localhost:5173` | +| `npm run build` | Builds the application for production | +| `npm run preview` | Builds and previews the production build locally | +| `npm run deploy` | Builds, syncs the schema and deploys to Cloudflare Workers| +| `npm run bknd` | Runs bknd CLI commands | +| `npm run bknd:types` | Generates TypeScript types from your schema | +| `npm run cf:types` | Generates Cloudflare Worker types from `wrangler.json` | +| `npm run check` | Type checks and does a dry-run deployment | + +## Development Workflow + +1. **Install dependencies:** + ```sh + npm install + ``` + +2. **Start development server:** + ```sh + npm run dev + ``` + +3. **Visit the Admin UI** at `http://localhost:5173/admin` to configure your backend visually: + - Create entities and fields + - Configure authentication + - Set up relationships + - Define permissions + +4. **Watch for auto-generated files:** + - `bknd-config.json` - Production configuration + - `bknd-types.d.ts` - TypeScript types + - `.env.example` - Required secrets + +5. **Use the CLI** for manual operations: + ```sh + # Generate types manually + npm run bknd:types + + # Sync the production database schema (only safe operations are applied) + CLOUDFLARE_ENV=production npm run bknd -- sync --force + ``` + +## Before you deploy + +If you're using a D1 database, make sure to create a database in your Cloudflare account and replace the `database_id` accordingly in `wrangler.json`: + +```sh +npx wrangler d1 create my-database +``` + +Update `wrangler.json`: +```json +{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-database", + "database_id": "your-database-id-here" + } + ] +} +``` + +## Deployment + +Deploy to Cloudflare Workers: + +```sh +npm run deploy +``` + +This will: +1. Set `ENVIRONMENT=production` to activate code-only mode +2. Build the Vite application +3. Deploy to Cloudflare Workers using Wrangler + +In production, bknd will: +- Use the configuration from `bknd-config.json` (read-only) +- Skip config validation for better performance +- Expect secrets to be provided via environment variables + +## Environment Variables + +Make sure to set your secrets in the Cloudflare Workers dashboard or via Wrangler: + +```sh +# Example: Set JWT secret +npx wrangler secret put auth.jwt.secret +``` + +Check `.env.example` for all required secrets after running the app in development mode. + +## How Hybrid Mode Works + +```mermaid +graph LR + A[Development] -->|Visual Config| B[Admin UI] + B -->|Auto-sync| C[bknd-config.json] + B -->|Auto-sync| D[bknd-types.d.ts] + C -->|Deploy| E[Production] + E -->|Read-only| F[Code-only Mode] +``` + +1. **In Development:** `mode: "db"` - Configuration stored in database, editable via Admin UI +2. **Auto-sync:** Changes automatically written to `bknd-config.json` and types to `bknd-types.d.ts` +3. **In Production:** `mode: "code"` - Configuration read from `bknd-config.json`, no database overhead + +## Why devFsVitePlugin? + +Cloudflare's Vite plugin removes Node.js APIs for Workers compatibility. This breaks filesystem operations needed for: +- Auto-syncing TypeScript types (`syncTypes` plugin) +- Auto-syncing configuration (`syncConfig` plugin) +- Auto-syncing secrets (`syncSecrets` plugin) + +The `devFsVitePlugin` + `devFsWrite` combination provides a workaround by using Vite's module system to enable file writes during development. + +## Want to learn more? + +- [Cloudflare Integration Documentation](https://docs.bknd.io/integration/cloudflare) +- [Hybrid Mode Guide](https://docs.bknd.io/usage/introduction#hybrid-mode) +- [Mode Helpers Documentation](https://docs.bknd.io/usage/introduction#mode-helpers) +- [Discord Community](https://discord.gg/952SFk8Tb8) + diff --git a/examples/cloudflare-vite-hybrid/bknd-config.json b/examples/cloudflare-vite-hybrid/bknd-config.json new file mode 100644 index 0000000..fb703aa --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd-config.json @@ -0,0 +1,204 @@ +{ + "server": { + "cors": { + "origin": "*", + "allow_methods": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "allow_credentials": true + }, + "mcp": { + "enabled": false, + "path": "/api/system/mcp", + "logLevel": "emergency" + } + }, + "data": { + "basepath": "/api/data", + "default_primary_format": "integer", + "entities": { + "todos": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false + } + }, + "done": { + "type": "boolean", + "config": { + "required": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "users": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "email": { + "type": "text", + "config": { + "required": true + } + }, + "strategy": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": [ + "password" + ] + }, + "required": true, + "hidden": [ + "update", + "form" + ], + "fillable": [ + "create" + ] + } + }, + "strategy_value": { + "type": "text", + "config": { + "fillable": [ + "create" + ], + "hidden": [ + "read", + "table", + "update", + "form" + ], + "required": true + } + }, + "role": { + "type": "enum", + "config": { + "options": { + "type": "objects", + "values": [] + }, + "required": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": {}, + "indices": { + "idx_unique_users_email": { + "entity": "users", + "fields": [ + "email" + ], + "unique": true + }, + "idx_users_strategy": { + "entity": "users", + "fields": [ + "strategy" + ], + "unique": false + }, + "idx_users_strategy_value": { + "entity": "users", + "fields": [ + "strategy_value" + ], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "", + "alg": "HS256", + "expires": 0, + "issuer": "bknd-cloudflare-example", + "fields": [ + "id", + "email", + "role" + ] + }, + "cookie": { + "domain": "", + "path": "/", + "sameSite": "strict", + "secure": true, + "httpOnly": true, + "expires": 604800, + "partitioned": false, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { + "hashing": "sha256" + } + } + }, + "guard": { + "enabled": false + }, + "roles": {} + }, + "media": { + "enabled": false, + "basepath": "/api/media", + "entity_name": "media", + "storage": {} + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/bknd-types.d.ts b/examples/cloudflare-vite-hybrid/bknd-types.d.ts new file mode 100644 index 0000000..db7bae6 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd-types.d.ts @@ -0,0 +1,22 @@ +import type { DB } from "bknd"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; + +declare global { + type BkndEntity = Selectable; + type BkndEntityCreate = Insertable; + type BkndEntityUpdate = Updateable; +} + +export interface Todos { + id: Generated; + title?: string; + done?: boolean; +} + +interface Database { + todos: Todos; +} + +declare module "bknd" { + interface DB extends Database {} +} \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/bknd.config.ts b/examples/cloudflare-vite-hybrid/bknd.config.ts new file mode 100644 index 0000000..8129c0e --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd.config.ts @@ -0,0 +1,15 @@ +/** + * This file gets automatically picked up by the bknd CLI. Since we're using cloudflare, + * we want to use cloudflare bindings (such as the database). To do this, we need to wrap + * the configuration with the `withPlatformProxy` helper function. + * + * Don't import this file directly in your app, otherwise "wrangler" will be bundled with your worker. + * That's why we split the configuration into two files: `bknd.config.ts` and `config.ts`. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); diff --git a/examples/cloudflare-vite-hybrid/config.ts b/examples/cloudflare-vite-hybrid/config.ts new file mode 100644 index 0000000..2177649 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/config.ts @@ -0,0 +1,47 @@ +/// + +import { devFsWrite, type CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { hybrid } from "bknd/modes"; + +export default hybrid({ + // normally you would use e.g. `readFile` from `node:fs/promises`, however, cloudflare using vite plugin removes all Node APIs, therefore we need to use the module system to import the config file + reader: async () => { + return (await import("./bknd-config.json").then((module) => module.default)) as any; + }, + // a writer is required to sync the types and config. We're using a vite plugin that proxies writing files (since Node APIs are not available) + writer: devFsWrite, + // the generated types are loaded using our tsconfig, and is automatically available in all bknd APIs + typesFilePath: "./bknd-types.d.ts", + // on every change, this config file is updated. When it's time to deploy, this will be inlined into your worker + configFilePath: "./bknd-config.json", + // secrets will always be extracted from the configuration, we're writing an example env file to know which secrets we need to provide prior to deploying + syncSecrets: { + enabled: true, + outFile: ".env.example", + format: "env", + } as const, + app: (env) => ({ + // we need to disable the admin controller using the vite plugin, since we want to render our own app + adminOptions: false, + // this is important to determine whether configuration should be read-only, or if the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + // we need to inject the secrets that gets merged into the configuration + secrets: env, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, + }), +}); diff --git a/examples/cloudflare-vite-hybrid/index.html b/examples/cloudflare-vite-hybrid/index.html new file mode 100644 index 0000000..5b7fd70 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/index.html @@ -0,0 +1,14 @@ + + + + + + + bknd + Vite + Cloudflare + React + TS + + + +
+ + + diff --git a/examples/cloudflare-vite-hybrid/package.json b/examples/cloudflare-vite-hybrid/package.json new file mode 100644 index 0000000..71e3ae9 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/package.json @@ -0,0 +1,36 @@ +{ + "name": "cloudflare-vite-fullstack-bknd", + "description": "A template for building a React application with Vite, Cloudflare Workers, and bknd", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "cf:types": "wrangler types", + "bknd": "node --experimental-strip-types node_modules/.bin/bknd", + "bknd:types": "bknd -- types", + "check": "tsc && vite build && wrangler deploy --dry-run", + "deploy": "CLOUDFLARE_ENV=production vite build && CLOUDFLARE_ENV=production npm run bknd -- sync --force && wrangler deploy", + "dev": "vite", + "preview": "npm run build && vite preview", + "postinstall": "npm run cf:types" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.17", + "bknd": "file:../../app", + "hono": "4.10.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.17", + "wouter": "^3.7.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "typescript": "5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } +} diff --git a/examples/cloudflare-vite-hybrid/public/vite.svg b/examples/cloudflare-vite-hybrid/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-hybrid/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/App.tsx b/examples/cloudflare-vite-hybrid/src/app/App.tsx new file mode 100644 index 0000000..9aa6a5f --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/App.tsx @@ -0,0 +1,39 @@ +import { Router, Switch, Route } from "wouter"; +import Home from "./routes/home.tsx"; +import { lazy, Suspense, useEffect, useState } from "react"; +const Admin = lazy(() => import("./routes/admin.tsx")); +import { useAuth } from "bknd/client"; + +export default function App() { + const auth = useAuth(); + const [verified, setVerified] = useState(false); + + useEffect(() => { + auth.verify().then(() => setVerified(true)); + }, []); + + if (!verified) return null; + + return ( + + + + + + + + + +
+ 404 +
+
+
+
+ ); +} diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg b/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg new file mode 100644 index 0000000..182ef92 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg b/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg new file mode 100644 index 0000000..3bb7ac0 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/react.svg b/examples/cloudflare-vite-hybrid/src/app/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg b/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/index.css b/examples/cloudflare-vite-hybrid/src/app/index.css new file mode 100644 index 0000000..4e0bdd8 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/index.css @@ -0,0 +1,28 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +@theme { + --color-background: var(--background); + --color-foreground: var(--foreground); +} + +body { + @apply bg-background text-foreground flex; + font-family: Arial, Helvetica, sans-serif; +} + +#root { + width: 100%; + min-height: 100dvh; +} diff --git a/examples/cloudflare-vite-hybrid/src/app/main.tsx b/examples/cloudflare-vite-hybrid/src/app/main.tsx new file mode 100644 index 0000000..8d55eb2 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { ClientProvider } from "bknd/client"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx b/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx new file mode 100644 index 0000000..93a4d9b --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx @@ -0,0 +1,8 @@ +import { Admin, type BkndAdminProps } from "bknd/ui"; +import "bknd/dist/styles.css"; +import { useAuth } from "bknd/client"; + +export default function AdminPage(props: BkndAdminProps) { + const auth = useAuth(); + return ; +} diff --git a/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx b/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx new file mode 100644 index 0000000..91cb1f2 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx @@ -0,0 +1,99 @@ +import { useAuth, useEntityQuery } from "bknd/client"; +import bkndLogo from "../assets/bknd.svg"; +import cloudflareLogo from "../assets/cloudflare.svg"; +import viteLogo from "../assets/vite.svg"; + +export default function Home() { + const auth = useAuth(); + + const limit = 5; + const { data: todos, ...$q } = useEntityQuery("todos", undefined, { + limit, + sort: "-id", + }); + + return ( +
+
+ bknd +
&
+
+ cloudflare +
+
+ vite +
+
+ +
+

+ What's next? +

+
+
+ {todos && + [...todos].reverse().map((todo) => ( +
+
+ { + await $q.update( + { done: !todo.done }, + todo.id + ); + }} + /> +
+ {todo.title} +
+
+ +
+ ))} +
+
t.id).join()} + action={async (formData: FormData) => { + const title = formData.get("title") as string; + await $q.create({ title }); + }} + > + + +
+
+
+ +
+ Go to Admin. ➝ +
+ {auth.user ? ( +

+ Authenticated as {auth.user.email} +

+ ) : ( + Login + )} +
+
+
+ ); +} diff --git a/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts b/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cloudflare-vite-hybrid/src/worker/index.ts b/examples/cloudflare-vite-hybrid/src/worker/index.ts new file mode 100644 index 0000000..7bf59ff --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/worker/index.ts @@ -0,0 +1,4 @@ +import { serve } from "bknd/adapter/cloudflare"; +import config from "../../config.ts"; + +export default serve(config); diff --git a/examples/cloudflare-vite-hybrid/tsconfig.app.json b/examples/cloudflare-vite-hybrid/tsconfig.app.json new file mode 100644 index 0000000..23edca7 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/app"] +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.json b/examples/cloudflare-vite-hybrid/tsconfig.json new file mode 100644 index 0000000..b3e17e0 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ], + "compilerOptions": { + "types": ["./worker-configuration.d.ts", "./bknd-types.d.ts", "node"] + } +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.node.json b/examples/cloudflare-vite-hybrid/tsconfig.node.json new file mode 100644 index 0000000..50130b3 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.worker.json b/examples/cloudflare-vite-hybrid/tsconfig.worker.json new file mode 100644 index 0000000..8a7758a --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["vite/client", "./worker-configuration.d.ts"] + }, + "include": ["src/worker", "src/config.ts"] +} diff --git a/examples/cloudflare-vite-hybrid/vite.config.ts b/examples/cloudflare-vite-hybrid/vite.config.ts new file mode 100644 index 0000000..0a83dae --- /dev/null +++ b/examples/cloudflare-vite-hybrid/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + react(), + // this plugin provides filesystem access during development + devFsVitePlugin({ configFile: "config.ts" }) as any, + tailwindcss(), + cloudflare(), + ], + build: { + minify: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + }, +}); diff --git a/examples/cloudflare-vite-hybrid/wrangler.json b/examples/cloudflare-vite-hybrid/wrangler.json new file mode 100644 index 0000000..d47fedd --- /dev/null +++ b/examples/cloudflare-vite-hybrid/wrangler.json @@ -0,0 +1,47 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-fullstack-bknd", + "main": "./src/worker/index.ts", + "compatibility_date": "2025-10-08", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "assets": { + "binding": "ASSETS", + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api*", "!/assets/*"] + }, + "vars": { + "ENVIRONMENT": "development" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ], + "env": { + "production": { + "vars": { + "ENVIRONMENT": "production" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ] + } + } +} diff --git a/package.json b/package.json index c7c9b9d..189c80d 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,16 @@ "private": true, "sideEffects": false, "type": "module", + "packageManager": "bun@1.3.3", + "engines": { + "node": ">=22.13" + }, "scripts": { "updater": "bun x npm-check-updates -ui", "ci": "find . -name 'node_modules' -type d -exec rm -rf {} + && bun install", "npm:local": "verdaccio --config verdaccio.yml", "format": "bunx biome format --write ./app", - "lint": "bunx biome lint --changed ./app" + "lint": "bunx biome lint --changed --write ./app" }, "dependencies": {}, "devDependencies": { @@ -20,8 +24,5 @@ "typescript": "^5.9.3", "verdaccio": "^6.2.1" }, - "engines": { - "node": ">=22" - }, "workspaces": ["app", "packages/*"] }