mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
Merge remote-tracking branch 'origin/release/0.20' into feat/postgres-fc
# Conflicts: # app/src/core/utils/runtime.ts
This commit is contained in:
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -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
19
.vscode/settings.json
vendored
Normal 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"]
|
||||
}
|
||||
}
|
||||
@@ -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
12
app/.vscode/settings.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
42
app/__test__/app/modes.test.ts
Normal file
42
app/__test__/app/modes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
40
app/__test__/auth/authorize/http/DataController.test.ts
Normal file
40
app/__test__/auth/authorize/http/DataController.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
41
app/__test__/auth/authorize/http/SystemController.test.ts
Normal file
41
app/__test__/auth/authorize/http/SystemController.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
171
app/__test__/auth/authorize/http/shared.ts
Normal file
171
app/__test__/auth/authorize/http/shared.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -186,6 +186,8 @@ async function buildUiElements() {
|
||||
outDir: "dist/ui/elements",
|
||||
external: [
|
||||
"ui/client",
|
||||
"bknd",
|
||||
/^bknd\/.*/,
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
app/src/core/drivers/email/plunk.spec.ts
Normal file
55
app/src/core/drivers/email/plunk.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
70
app/src/core/drivers/email/plunk.ts
Normal file
70
app/src/core/drivers/email/plunk.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") }),
|
||||
}),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
683
app/src/plugins/auth/email-otp.plugin.spec.ts
Normal file
683
app/src/plugins/auth/email-otp.plugin.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
387
app/src/plugins/auth/email-otp.plugin.ts
Normal file
387
app/src/plugins/auth/email-otp.plugin.ts
Normal 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",
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ?? "" });
|
||||
}
|
||||
|
||||
@@ -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 = <
|
||||
|
||||
@@ -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 = <
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
type ClientProviderProps,
|
||||
useApi,
|
||||
useBaseUrl,
|
||||
useClientContext
|
||||
} from "./ClientProvider";
|
||||
|
||||
export * from "./api/use-api";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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();
|
||||
|
||||
56
biome.json
56
biome.json
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.3/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "off" } } },
|
||||
"vcs": {
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
"indentStyle": "space",
|
||||
"formatWithErrors": true,
|
||||
"includes": ["**", "!!**/package.json"]
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
@@ -20,6 +20,9 @@
|
||||
"css": {
|
||||
"formatter": {
|
||||
"indentWidth": 3
|
||||
},
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
@@ -30,32 +33,37 @@
|
||||
}
|
||||
},
|
||||
"files": {
|
||||
"ignore": [
|
||||
"**/node_modules/**",
|
||||
"node_modules/**",
|
||||
"**/.cache/**",
|
||||
"**/.wrangler/**",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/data.sqld/**",
|
||||
"data.sqld/**",
|
||||
"public/**",
|
||||
".history/**"
|
||||
"includes": [
|
||||
"**",
|
||||
"!!**/.tsup",
|
||||
"!!**/node_modules",
|
||||
"!!**/.cache",
|
||||
"!!**/.wrangler",
|
||||
"!!**/build",
|
||||
"!!**/dist",
|
||||
"!!**/data.sqld",
|
||||
"!!**/data.sqld",
|
||||
"!!**/public",
|
||||
"!!**/.history"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"ignore": ["**/*.spec.ts"],
|
||||
"includes": ["**", "!!**/vitest.config.ts", "!!app/build.ts"],
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"all": false
|
||||
},
|
||||
"a11y": {},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
"noUnreachable": "warn",
|
||||
"noChildrenProp": "off",
|
||||
"noSwitchDeclarations": "warn"
|
||||
"noSwitchDeclarations": "warn",
|
||||
"noUnusedVariables": {
|
||||
"options": {
|
||||
"ignoreRestSiblings": true
|
||||
},
|
||||
"level": "warn"
|
||||
}
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessFragments": "warn",
|
||||
@@ -70,7 +78,11 @@
|
||||
"noArrayIndexKey": "off",
|
||||
"noImplicitAnyLet": "warn",
|
||||
"noConfusingVoidType": "off",
|
||||
"noConsoleLog": "warn"
|
||||
"noConsole": {
|
||||
"level": "warn",
|
||||
"options": { "allow": ["error", "info"] }
|
||||
},
|
||||
"noTsIgnore": "off"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off"
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bknd",
|
||||
@@ -3864,6 +3865,8 @@
|
||||
|
||||
"@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||
|
||||
"@bknd/plasmic/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="],
|
||||
@@ -4756,6 +4759,8 @@
|
||||
|
||||
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="],
|
||||
|
||||
"@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
|
||||
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
|
||||
@@ -5252,6 +5257,8 @@
|
||||
|
||||
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
|
||||
|
||||
"@bknd/plasmic/@types/bun/bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="],
|
||||
|
||||
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="],
|
||||
@@ -5472,6 +5479,8 @@
|
||||
|
||||
"wrangler/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"@bknd/plasmic/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
[install]
|
||||
#linker = "hoisted"
|
||||
linker = "isolated"
|
||||
14
docker/debug/Dockerfile.minimal
Normal file
14
docker/debug/Dockerfile.minimal
Normal 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
12
docker/debug/run-minimal.sh
Executable 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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
3
examples/.gitignore
vendored
@@ -2,4 +2,5 @@
|
||||
*/bun.lock
|
||||
*/deno.lock
|
||||
*/node_modules
|
||||
*/*.db
|
||||
*/*.db
|
||||
*/worker-configuration.d.ts
|
||||
1
examples/cloudflare-vite-code/.env.local
Normal file
1
examples/cloudflare-vite-code/.env.local
Normal file
@@ -0,0 +1 @@
|
||||
JWT_SECRET=secret
|
||||
348
examples/cloudflare-vite-code/README.md
Normal file
348
examples/cloudflare-vite-code/README.md
Normal 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)
|
||||
|
||||
15
examples/cloudflare-vite-code/bknd.config.ts
Normal file
15
examples/cloudflare-vite-code/bknd.config.ts
Normal 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,
|
||||
});
|
||||
43
examples/cloudflare-vite-code/config.ts
Normal file
43
examples/cloudflare-vite-code/config.ts
Normal 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`.
|
||||
}),
|
||||
});
|
||||
14
examples/cloudflare-vite-code/index.html
Normal file
14
examples/cloudflare-vite-code/index.html
Normal 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>
|
||||
36
examples/cloudflare-vite-code/package.json
Normal file
36
examples/cloudflare-vite-code/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
examples/cloudflare-vite-code/public/vite.svg
Normal file
1
examples/cloudflare-vite-code/public/vite.svg
Normal 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 |
28
examples/cloudflare-vite-code/seed.ts
Normal file
28
examples/cloudflare-vite-code/seed.ts
Normal 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);
|
||||
39
examples/cloudflare-vite-code/src/app/App.tsx
Normal file
39
examples/cloudflare-vite-code/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
examples/cloudflare-vite-code/src/app/assets/bknd.svg
Normal file
14
examples/cloudflare-vite-code/src/app/assets/bknd.svg
Normal 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 |
51
examples/cloudflare-vite-code/src/app/assets/cloudflare.svg
Normal file
51
examples/cloudflare-vite-code/src/app/assets/cloudflare.svg
Normal 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 |
1
examples/cloudflare-vite-code/src/app/assets/react.svg
Normal file
1
examples/cloudflare-vite-code/src/app/assets/react.svg
Normal 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 |
1
examples/cloudflare-vite-code/src/app/assets/vite.svg
Normal file
1
examples/cloudflare-vite-code/src/app/assets/vite.svg
Normal 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 |
28
examples/cloudflare-vite-code/src/app/index.css
Normal file
28
examples/cloudflare-vite-code/src/app/index.css
Normal 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;
|
||||
}
|
||||
13
examples/cloudflare-vite-code/src/app/main.tsx
Normal file
13
examples/cloudflare-vite-code/src/app/main.tsx
Normal 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>
|
||||
);
|
||||
8
examples/cloudflare-vite-code/src/app/routes/admin.tsx
Normal file
8
examples/cloudflare-vite-code/src/app/routes/admin.tsx
Normal 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 }} />;
|
||||
}
|
||||
94
examples/cloudflare-vite-code/src/app/routes/home.tsx
Normal file
94
examples/cloudflare-vite-code/src/app/routes/home.tsx
Normal 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">&</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>
|
||||
);
|
||||
}
|
||||
1
examples/cloudflare-vite-code/src/app/vite-env.d.ts
vendored
Normal file
1
examples/cloudflare-vite-code/src/app/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
7
examples/cloudflare-vite-code/src/worker/index.ts
Normal file
7
examples/cloudflare-vite-code/src/worker/index.ts
Normal 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
Reference in New Issue
Block a user