Merge remote-tracking branch 'origin/release/0.20' into feat/postgres-fc

# Conflicts:
#	app/src/core/utils/runtime.ts
This commit is contained in:
dswbx
2025-12-02 10:20:58 +01:00
133 changed files with 3896 additions and 244 deletions

View File

@@ -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

3
.gitignore vendored
View File

@@ -27,7 +27,8 @@ packages/media/.env
.npmrc
/.verdaccio
.idea
.vscode
.vscode/*
!.vscode/settings.json
.git_old
docker/tmp
.debug

19
.vscode/settings.json vendored Normal file
View File

@@ -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"]
}
}

View File

@@ -20,6 +20,7 @@ VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
PLUNK_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=

12
app/.vscode/settings.json vendored Normal file
View File

@@ -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
}
}

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});

View File

@@ -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<CreateAppConfig> = {}) {
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));
});
});

View File

@@ -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);
});
});

View File

@@ -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<CreateAppConfig> = {},
) {
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<keyof typeof users, string>;
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<string, string>;
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<string, string>;
data: any;
}>;
};
return { app, users, request, requestFn };
}

View File

@@ -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 () => {

View File

@@ -186,6 +186,8 @@ async function buildUiElements() {
outDir: "dist/ui/elements",
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"react",
"react-dom",
"react/jsx-runtime",

View File

@@ -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"
},

View File

@@ -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`);
}
}
}
}

View File

@@ -43,6 +43,7 @@ export function createHandler<Env = BunEnv>(
export function serve<Env = BunEnv>(
{
app,
distPath,
connection,
config: _config,
@@ -62,6 +63,7 @@ export function serve<Env = BunEnv>(
port,
fetch: createHandler(
{
app,
connection,
config: _config,
options,

View File

@@ -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<Env extends CloudflareEnv = CloudflareEnv>(
// compatiblity
export const getFresh = createApp;
let app: App | undefined;
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
serveOptions?: (args: Env) => {
warm?: boolean;
},
) {
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
@@ -92,8 +96,11 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
}
}
const context = { request, env, ctx } as CloudflareContext<Env>;
const app = await createApp(config, context);
const { warm } = serveOptions?.(env) ?? {};
if (!app || warm !== true) {
const context = { request, env, ctx } as CloudflareContext<Env>;
app = await createApp(config, context);
}
return app.fetch(request, env, ctx);
},

View File

@@ -65,37 +65,31 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
}
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<Env>;
}

View File

@@ -14,14 +14,15 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite";
export type BkndConfig<Args = any, Additional = {}> = Merge<
CreateAppConfig & {
app?:
| Merge<Omit<BkndConfig, "app"> & Additional>
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0];
} & Additional
CreateAppConfig &
Omit<Additional, "app"> & {
app?:
| Omit<BkndConfig<Args, Additional>, "app">
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0];
}
>;
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;

View File

@@ -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(), {

View File

@@ -24,7 +24,7 @@ export async function createApp<Env = NodeEnv>(
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();

View File

@@ -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");
});
}
}

View File

@@ -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 = "<h1>Test Email</h1><p>This is a test email</p>";
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: "<p>This is HTML</p>",
});
expect(response).toBeDefined();
expect(response.success).toBe(true);
});
});

View File

@@ -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<string, string>;
};
export type PlunkEmailResponse = {
success: boolean;
emails: Array<{
contact: {
id: string;
email: string;
};
email: string;
}>;
timestamp: string;
};
export const plunkEmail = (
config: PlunkEmailOptions,
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
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;
},
};
};

View File

@@ -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";

View File

@@ -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<E extends Event<any, any>> = (
event: E,
slug: string,
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
) => E extends Event<any, infer R> ? MaybePromise<R | void> : never;
export class EventListener<E extends Event = Event> {
mode: ListenerMode = "async";

View File

@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
export function omitKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[],
keys_: readonly K[] | K[] | string[],
): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const keys = new Set(keys_ as readonly K[]);
const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) {

View File

@@ -79,6 +79,22 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
}
}
export async function threwAsync(fn: Promise<any>, 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<boolean>,

View File

@@ -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: <explanation>
.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 {

View File

@@ -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") }),
}),

View File

@@ -103,6 +103,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
validated.with = options.with;
}
// add explicit joins. Implicit joins are added in `where` builder
if (options.join && options.join.length > 0) {
for (const entry of options.join) {
const related = this.em.relationOf(entity.name, entry);
@@ -127,12 +128,28 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
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");

View File

@@ -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<any>, 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;
}

View File

@@ -26,7 +26,12 @@ export class JsonSchemaField<
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
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() {

View File

@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
switch (context) {
case "submit":
return Number.parseInt(value);
return Number.parseInt(value, 10);
}
return value;

View File

@@ -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;

View File

@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
withConnection(connection: Connection): EntityManager<Schema<Entities>> {
withConnection(connection: Connection): EntityManager<Schemas<Entities>> {
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
}
}

View File

@@ -10,16 +10,19 @@ export type CodeMode<AdapterConfig extends BkndConfig> = AdapterConfig extends B
? BkndModeConfig<Args, AdapterConfig>
: never;
export function code<Args>(config: BkndCodeModeConfig<Args>): BkndConfig<Args> {
export function code<
Config extends BkndConfig,
Args = Config extends BkndConfig<infer A> ? A : unknown,
>(codeConfig: CodeMode<Config>): BkndConfig<Args> {
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");

View File

@@ -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<string>;
reader?: (path: string) => MaybePromise<string | object>;
/**
* Provided secrets to be merged into the configuration
*/
@@ -23,42 +23,36 @@ export type HybridMode<AdapterConfig extends BkndConfig> = AdapterConfig extends
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
: never;
export function hybrid<Args>({
configFilePath = "bknd-config.json",
...rest
}: HybridBkndConfig<Args>): BkndConfig<Args> {
export function hybrid<
Config extends BkndConfig,
Args = Config extends BkndConfig<infer A> ? A : unknown,
>(hybridConfig: HybridMode<Config>): BkndConfig<Args> {
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<Args>({
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,
},
},

View File

@@ -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<Args = any, Additional = {}> = BkndConfig<
Merge<BkndModeOptions & Additional>
>;
function _isProd() {
try {
return process.env.NODE_ENV === "production";
} catch (_e) {
return false;
}
}
export async function makeModeConfig<
Args = any,
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
@@ -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,

View File

@@ -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[] = [];

View File

@@ -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;
}
}

View File

@@ -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");

View File

@@ -105,7 +105,10 @@ export class AppServer extends Module<AppServerConfig> {
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,
);
}
}

View File

@@ -1,5 +1,3 @@
/// <reference types="@cloudflare/workers-types" />
import type { App } from "App";
import {
datetimeStringLocal,
@@ -125,7 +123,7 @@ export class SystemController extends Controller {
private registerConfigController(client: Hono<any>): void {
const { permission } = this.middlewares;
// don't add auth again, it's already added in getController
const hono = this.create(); /* .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;
}

View File

@@ -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);
}
});
});

View File

@@ -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<DB["users"], "email">) => 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<typeof otpFields>;
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<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
user: Pick<DB["users"], "email">,
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<Pick<EmailOTPPluginOptions, "generateEmail">>,
) {
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",
},
);
});
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -14,18 +14,20 @@ const ClientContext = createContext<BkndClientContext>(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<Partial<AuthState> | 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 ?? "" });
}

View File

@@ -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 = <

View File

@@ -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<Payload = any> 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<infer T> ? T : I) : never,
Response = ResponseObject<RepositoryResult<Selectable<Data>>>,
> {
create: (input: Insertable<Data>) => Promise<Response>;
@@ -42,9 +43,11 @@ interface UseEntityReturn<
ResponseObject<RepositoryResult<Id extends undefined ? Selectable<Data>[] : Selectable<Data>>>
>;
update: Id extends undefined
? (input: Updateable<Data>, id: Id) => Promise<Response>
? (input: Updateable<Data>, id: ActualId) => Promise<Response>
: (input: Updateable<Data>) => Promise<Response>;
_delete: Id extends undefined ? (id: Id) => Promise<Response> : () => Promise<Response>;
_delete: Id extends undefined
? (id: PrimaryFieldType) => Promise<Response>
: () => Promise<Response>;
}
export const useEntity = <

View File

@@ -4,6 +4,7 @@ export {
type ClientProviderProps,
useApi,
useBaseUrl,
useClientContext
} from "./ClientProvider";
export * from "./api/use-api";

View File

@@ -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;

View File

@@ -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<AppAuthSchema, "strategies" | "basepath">;
export const useAuthStrategies = (options?: { baseUrl?: string }): Partial<AuthStrategyData> & {
export const useAuthStrategies = (options?: {
baseUrl?: string;
}): Partial<AuthStrategyData> & {
loading: boolean;
} => {
const [data, setData] = useState<AuthStrategyData>();

View File

@@ -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 = {

View File

@@ -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 (
<>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
</>
<Dropzone
key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={_initialItems}
footer={
infinite &&
"setSize" in $q && (
<Footer
items={_initialItems.length}
length={placeholderLength}
onFirstVisible={() => $q.setSize($q.size + 1)}
/>
)
}
{...props}
/>
);
}

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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 (
<JsonSchemaForm

View File

@@ -11,7 +11,7 @@ import SettingsRoutes from "./settings";
import { FlashMessage } from "ui/modules/server/FlashMessage";
import { AuthRegister } from "ui/routes/auth/auth.register";
import { BkndModalsProvider } from "ui/modals";
import { useBkndWindowContext } from "ui/client";
import { useBkndWindowContext } from "bknd/client";
import ToolsRoutes from "./tools";
// @ts-ignore

View File

@@ -1,11 +1,12 @@
import { IconPhoto } from "@tabler/icons-react";
import { useBknd } from "ui/client/BkndProvider";
import { Empty } from "ui/components/display/Empty";
import { type FileState, Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import * as AppShell from "ui/layouts/AppShell/AppShell";
import { useLocation } from "wouter";
import { bkndModals } from "ui/modals";
import { DropzoneContainer } from "ui/elements/media/DropzoneContainer";
import type { FileState } from "ui/elements/media/Dropzone";
export function MediaIndex() {
const { config } = useBknd();
@@ -35,7 +36,7 @@ export function MediaIndex() {
return (
<AppShell.Scrollable>
<div className="flex flex-1 p-3">
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
<DropzoneContainer onClick={onClick} infinite query={{ sort: "-id" }} />
</div>
</AppShell.Scrollable>
);

View File

@@ -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";

View File

@@ -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() {

View File

@@ -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": [

View File

@@ -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();

View File

@@ -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"

View File

@@ -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=="],

View File

@@ -1,2 +1,2 @@
[install]
#linker = "hoisted"
linker = "isolated"

View File

@@ -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"]

12
docker/debug/run-minimal.sh Executable file
View File

@@ -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

View File

@@ -261,3 +261,77 @@ export default {
```
### `emailOTP`
<Callout type="warning">
Make sure to setup proper permissions to restrict reading from the OTP entity. Also, this plugin requires the `email` driver to be registered.
</Callout>
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;
```
<AutoTypeTable path="../app/src/plugins/auth/email-otp.plugin.ts" name="EmailOTPPluginOptions" />

View File

@@ -40,12 +40,6 @@ bun add bknd
</Tabs>
<Callout type="info">
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).
</Callout>
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();

View File

@@ -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<BunBkndConfig>({
// 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<BunBkndConfig>;
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<BunBkndConfig>({
// 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<BunBkndConfig>;
export default hybrid(config);
});
```

3
examples/.gitignore vendored
View File

@@ -2,4 +2,5 @@
*/bun.lock
*/deno.lock
*/node_modules
*/*.db
*/*.db
*/worker-configuration.d.ts

View File

@@ -0,0 +1 @@
JWT_SECRET=secret

View File

@@ -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 <name>`).
- **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 `<Admin />` 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<CloudflareBkndConfig>({
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)

View File

@@ -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,
});

View File

@@ -0,0 +1,43 @@
/// <reference types="./worker-configuration.d.ts" />
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<CloudflareBkndConfig>({
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`.
}),
});

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>bknd + Vite + Cloudflare + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,28 @@
/// <reference types="./worker-configuration.d.ts" />
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);

View File

@@ -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 (
<Router>
<Switch>
<Route path="/" component={Home} />
<Route path="/admin/*?">
<Suspense>
<Admin
config={{
basepath: "/admin",
logo_return_path: "/../",
}}
/>
</Suspense>
</Route>
<Route path="*">
<div className="w-full min-h-full flex justify-center items-center font-mono text-4xl">
404
</div>
</Route>
</Switch>
</Router>
);
}

View File

@@ -0,0 +1,14 @@
<svg
width="578"
height="188"
viewBox="0 0 578 188"
fill="black"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M41.5 34C37.0817 34 33.5 37.5817 33.5 42V146C33.5 150.418 37.0817 154 41.5 154H158.5C162.918 154 166.5 150.418 166.5 146V42C166.5 37.5817 162.918 34 158.5 34H41.5ZM123.434 113.942C124.126 111.752 124.5 109.42 124.5 107C124.5 94.2975 114.203 84 101.5 84C99.1907 84 96.9608 84.3403 94.8579 84.9736L87.2208 65.1172C90.9181 63.4922 93.5 59.7976 93.5 55.5C93.5 49.701 88.799 45 83 45C77.201 45 72.5 49.701 72.5 55.5C72.5 61.299 77.201 66 83 66C83.4453 66 83.8841 65.9723 84.3148 65.9185L92.0483 86.0256C87.1368 88.2423 83.1434 92.1335 80.7957 96.9714L65.4253 91.1648C65.4746 90.7835 65.5 90.3947 65.5 90C65.5 85.0294 61.4706 81 56.5 81C51.5294 81 47.5 85.0294 47.5 90C47.5 94.9706 51.5294 99 56.5 99C60.0181 99 63.0648 96.9814 64.5449 94.0392L79.6655 99.7514C78.9094 102.03 78.5 104.467 78.5 107C78.5 110.387 79.2321 113.603 80.5466 116.498L69.0273 123.731C67.1012 121.449 64.2199 120 61 120C55.201 120 50.5 124.701 50.5 130.5C50.5 136.299 55.201 141 61 141C66.799 141 71.5 136.299 71.5 130.5C71.5 128.997 71.1844 127.569 70.6158 126.276L81.9667 119.149C86.0275 125.664 93.2574 130 101.5 130C110.722 130 118.677 124.572 122.343 116.737L132.747 120.899C132.585 121.573 132.5 122.276 132.5 123C132.5 127.971 136.529 132 141.5 132C146.471 132 150.5 127.971 150.5 123C150.5 118.029 146.471 114 141.5 114C138.32 114 135.525 115.649 133.925 118.139L123.434 113.942Z"
/>
<path d="M243.9 151.5C240.4 151.5 237 151 233.7 150C230.4 149 227.4 147.65 224.7 145.95C222 144.15 219.75 142.15 217.95 139.95C216.15 137.65 215 135.3 214.5 132.9L219.3 131.1L218.25 149.7H198.15V39H219.45V89.25L215.4 87.6C216 85.2 217.15 82.9 218.85 80.7C220.55 78.4 222.7 76.4 225.3 74.7C227.9 72.9 230.75 71.5 233.85 70.5C236.95 69.5 240.15 69 243.45 69C250.35 69 256.5 70.8 261.9 74.4C267.3 77.9 271.55 82.75 274.65 88.95C277.85 95.15 279.45 102.25 279.45 110.25C279.45 118.25 277.9 125.35 274.8 131.55C271.7 137.75 267.45 142.65 262.05 146.25C256.75 149.75 250.7 151.5 243.9 151.5ZM238.8 133.35C242.8 133.35 246.25 132.4 249.15 130.5C252.15 128.5 254.5 125.8 256.2 122.4C257.9 118.9 258.75 114.85 258.75 110.25C258.75 105.75 257.9 101.75 256.2 98.25C254.6 94.75 252.3 92.05 249.3 90.15C246.3 88.25 242.8 87.3 238.8 87.3C234.8 87.3 231.3 88.25 228.3 90.15C225.3 92.05 222.95 94.75 221.25 98.25C219.55 101.75 218.7 105.75 218.7 110.25C218.7 114.85 219.55 118.9 221.25 122.4C222.95 125.8 225.3 128.5 228.3 130.5C231.3 132.4 234.8 133.35 238.8 133.35ZM308.312 126.15L302.012 108.6L339.512 70.65H367.562L308.312 126.15ZM288.062 150V39H309.362V150H288.062ZM341.762 150L313.262 114.15L328.262 102.15L367.412 150H341.762ZM371.675 150V70.65H392.075L392.675 86.85L388.475 88.65C389.575 85.05 391.525 81.8 394.325 78.9C397.225 75.9 400.675 73.5 404.675 71.7C408.675 69.9 412.875 69 417.275 69C423.275 69 428.275 70.2 432.275 72.6C436.375 75 439.425 78.65 441.425 83.55C443.525 88.35 444.575 94.3 444.575 101.4V150H423.275V103.05C423.275 99.45 422.775 96.45 421.775 94.05C420.775 91.65 419.225 89.9 417.125 88.8C415.125 87.6 412.625 87.1 409.625 87.3C407.225 87.3 404.975 87.7 402.875 88.5C400.875 89.2 399.125 90.25 397.625 91.65C396.225 93.05 395.075 94.65 394.175 96.45C393.375 98.25 392.975 100.2 392.975 102.3V150H382.475C380.175 150 378.125 150 376.325 150C374.525 150 372.975 150 371.675 150ZM488.536 151.5C481.636 151.5 475.436 149.75 469.936 146.25C464.436 142.65 460.086 137.8 456.886 131.7C453.786 125.5 452.236 118.35 452.236 110.25C452.236 102.35 453.786 95.3 456.886 89.1C460.086 82.9 464.386 78 469.786 74.4C475.286 70.8 481.536 69 488.536 69C492.236 69 495.786 69.6 499.186 70.8C502.686 71.9 505.786 73.45 508.486 75.45C511.286 77.45 513.536 79.7 515.236 82.2C516.936 84.6 517.886 87.15 518.086 89.85L512.686 90.75V39H533.986V150H513.886L512.986 131.7L517.186 132.15C516.986 134.65 516.086 137.05 514.486 139.35C512.886 141.65 510.736 143.75 508.036 145.65C505.436 147.45 502.436 148.9 499.036 150C495.736 151 492.236 151.5 488.536 151.5ZM493.336 133.8C497.336 133.8 500.836 132.8 503.836 130.8C506.836 128.8 509.186 126.05 510.886 122.55C512.586 119.05 513.436 114.95 513.436 110.25C513.436 105.65 512.586 101.6 510.886 98.1C509.186 94.5 506.836 91.75 503.836 89.85C500.836 87.85 497.336 86.85 493.336 86.85C489.336 86.85 485.836 87.85 482.836 89.85C479.936 91.75 477.636 94.5 475.936 98.1C474.336 101.6 473.536 105.65 473.536 110.25C473.536 114.95 474.336 119.05 475.936 122.55C477.636 126.05 479.936 128.8 482.836 130.8C485.836 132.8 489.336 133.8 493.336 133.8Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 822.8 355.5" style="enable-background:new 0 0 822.8 355.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#FBAE40;}
.st2{fill:#F58220;}
</style>
<g id="Page-1">
<path id="CLOUDFLARE-_xAE_" class="st0" d="M772.2,252.6c-3.4,0-6.1-2.7-6.1-6.1c0-3.3,2.7-6.1,6.1-6.1c3.3,0,6.1,2.7,6.1,6.1
C778.3,249.8,775.5,252.6,772.2,252.6L772.2,252.6z M772.2,241.6c-2.7,0-4.9,2.2-4.9,4.9s2.2,4.9,4.9,4.9c2.7,0,4.9-2.2,4.9-4.9
S774.9,241.6,772.2,241.6L772.2,241.6z M775.3,249.7h-1.4l-1.2-2.3h-1.6v2.3h-1.3V243h3.2c1.4,0,2.3,0.9,2.3,2.2c0,1-0.6,1.7-1.4,2
L775.3,249.7z M772.9,246.2c0.5,0,1-0.3,1-1c0-0.8-0.4-1-1-1h-2v2H772.9z M136.7,239.8h15.6v42.5h27.1v13.6h-42.7V239.8z
M195.5,268v-0.2c0-16.1,13-29.2,30.3-29.2s30.1,12.9,30.1,29v0.2c0,16.1-13,29.2-30.3,29.2S195.5,284.1,195.5,268z M240.1,268
v-0.2c0-8.1-5.8-15.1-14.4-15.1c-8.5,0-14.2,6.9-14.2,15v0.2c0,8.1,5.8,15.1,14.3,15.1C234.4,283,240.1,276.1,240.1,268z
M275,271.3v-31.5h15.8V271c0,8.1,4.1,11.9,10.3,11.9c6.2,0,10.3-3.7,10.3-11.5v-31.6h15.8v31.1c0,18.1-10.3,26-26.3,26
C285,296.9,275,288.9,275,271.3z M351,239.8h21.6c20,0,31.7,11.5,31.7,27.7v0.2c0,16.2-11.8,28.2-32,28.2H351V239.8z M372.9,282.1
c9.3,0,15.5-5.1,15.5-14.2v-0.2c0-9-6.2-14.2-15.5-14.2h-6.3V282L372.9,282.1L372.9,282.1z M426.9,239.8h44.9v13.6h-29.4v9.6H469
v12.9h-26.6v20h-15.5V239.8z M493.4,239.8h15.5v42.5h27.2v13.6h-42.7V239.8z M576.7,239.4h15l23.9,56.5h-16.7l-4.1-10h-21.6l-4,10
h-16.3L576.7,239.4z M590.4,273.8l-6.2-15.9l-6.3,15.9H590.4z M635.6,239.8h26.5c8.6,0,14.5,2.2,18.3,6.1c3.3,3.2,5,7.5,5,13.1v0.2
c0,8.6-4.6,14.3-11.5,17.2l13.4,19.6h-18L658,279h-6.8v17h-15.6V239.8z M661.4,266.7c5.3,0,8.3-2.6,8.3-6.6v-0.2
c0-4.4-3.2-6.6-8.4-6.6h-10.2v13.4H661.4z M707.8,239.8h45.1V253h-29.7v8.5h26.9v12.3h-26.9v8.9h30.1v13.2h-45.5V239.8z
M102.7,274.6c-2.2,4.9-6.8,8.4-12.8,8.4c-8.5,0-14.3-7.1-14.3-15.1v-0.2c0-8.1,5.7-15,14.2-15c6.4,0,11.3,3.9,13.3,9.3h16.4
c-2.6-13.4-14.4-23.3-29.6-23.3c-17.3,0-30.3,13.1-30.3,29.2v0.2c0,16.1,12.8,29,30.1,29c14.8,0,26.4-9.6,29.4-22.4L102.7,274.6z"
/>
<path id="flare" class="st0" d="M734.5,150.4l-40.7-24.7c-0.6-0.1-4.4,0.3-6.4-0.7c-1.4-0.7-2.5-1.9-3.2-4c-3.2,0-175.5,0-175.5,0
v91.8h225.8V150.4z"/>
<path id="right-cloud" class="st1" d="M692.2,125.8c-0.8,0-1.5,0.6-1.8,1.4l-4.8,16.7c-2.1,7.2-1.3,13.8,2.2,18.7
c3.2,4.5,8.6,7.1,15.1,7.4l26.2,1.6c0.8,0,1.5,0.4,1.9,1c0.4,0.6,0.5,1.5,0.3,2.2c-0.4,1.2-1.6,2.1-2.9,2.2l-27.3,1.6
c-14.8,0.7-30.7,12.6-36.3,27.2l-2,5.1c-0.4,1,0.3,2,1.4,2H758c1.1,0,2.1-0.7,2.4-1.8c1.6-5.8,2.5-11.9,2.5-18.2
c0-37-30.2-67.2-67.3-67.2C694.5,125.7,693.3,125.7,692.2,125.8z"/>
<path id="left-cloud" class="st2" d="M656.4,204.6c2.1-7.2,1.3-13.8-2.2-18.7c-3.2-4.5-8.6-7.1-15.1-7.4L516,176.9
c-0.8,0-1.5-0.4-1.9-1c-0.4-0.6-0.5-1.4-0.3-2.2c0.4-1.2,1.6-2.1,2.9-2.2l124.2-1.6c14.7-0.7,30.7-12.6,36.3-27.2l7.1-18.5
c0.3-0.8,0.4-1.6,0.2-2.4c-8-36.2-40.3-63.2-78.9-63.2c-35.6,0-65.8,23-76.6,54.9c-7-5.2-15.9-8-25.5-7.1
c-17.1,1.7-30.8,15.4-32.5,32.5c-0.4,4.4-0.1,8.7,0.9,12.7c-27.9,0.8-50.2,23.6-50.2,51.7c0,2.5,0.2,5,0.5,7.5
c0.2,1.2,1.2,2.1,2.4,2.1h227.2c1.3,0,2.5-0.9,2.9-2.2L656.4,204.6z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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;
}

View File

@@ -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(
<StrictMode>
<ClientProvider>
<App />
</ClientProvider>
</StrictMode>
);

View File

@@ -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 <Admin {...props} withProvider={{ user: auth.user }} />;
}

View File

@@ -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 (
<div className="flex-col gap-10 max-w-96 mx-auto w-full min-h-full flex justify-center items-center">
<div className="flex flex-row items-center gap-3">
<img src={bkndLogo} alt="bknd" className="w-48 dark:invert" />
<div className="font-mono opacity-70">&amp;</div>
<div className="flex flex-row gap-2 items-center">
<img src={cloudflareLogo} alt="cloudflare" className="h-10" />
<div className="font-mono opacity-70">+</div>
<img src={viteLogo} alt="vite" className="w-10" />
</div>
</div>
<div className="flex flex-col border border-foreground/15 w-full py-4 px-5 gap-2">
<h2 className="font-mono mb-1 opacity-70">
<code>What's next?</code>
</h2>
<div className="flex flex-col w-full gap-2">
<div className="flex flex-col gap-3">
{todos &&
[...todos].reverse().map((todo) => (
<div className="flex flex-row" key={String(todo.id)}>
<div className="flex flex-row flex-grow items-center gap-3 ml-1">
<input
type="checkbox"
className="flex-shrink-0 cursor-pointer"
defaultChecked={!!todo.done}
onChange={async () => {
await $q.update({ done: !todo.done }, todo.id);
}}
/>
<div className="text-foreground/90 leading-none">{todo.title}</div>
</div>
<button
type="button"
className="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs "
onClick={async () => {
await $q._delete(todo.id);
}}
>
</button>
</div>
))}
</div>
<form
className="flex flex-row w-full gap-3 mt-2"
key={todos?.map((t) => t.id).join()}
action={async (formData: FormData) => {
const title = formData.get("title") as string;
await $q.create({ title });
}}
>
<input
type="text"
name="title"
placeholder="New todo"
className="py-2 px-4 flex flex-grow rounded-sm bg-foreground/10 focus:bg-foreground/20 transition-colors outline-none"
/>
<button type="submit" className="cursor-pointer">
Add
</button>
</form>
</div>
</div>
<div className="flex flex-col items-center gap-1">
<a href="/admin">Go to Admin. </a>
<div className="opacity-50 text-sm">
{auth.user ? (
<p>
Authenticated as <b>{auth.user.email}</b>
</p>
) : (
<a href="/admin/auth/login">Login</a>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -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,
}));

Some files were not shown because too many files have changed in this diff Show More