Compare commits

...

41 Commits

Author SHA1 Message Date
dswbx 7f8d8d26ac Merge pull request #259 from bknd-io/release/0.17.2
Bugfix Release 0.17.2
2025-09-15 10:15:14 +02:00
dswbx 655bb241a8 bump version to 0.17.2 2025-09-15 10:14:14 +02:00
dswbx 1ef5bf7d15 fix: adjust mcp schema validation and add default path support
Updated `mcp.json` to enhance validation by supporting `anyOf` schema for `entity` properties and refined `role` typing. Introduced default `path` property for MCP system API and aligned build assets configuration accordingly.
2025-09-15 10:09:51 +02:00
dswbx 9a1589da0b bump @bknd/postgres version to 0.2.0 2025-09-15 10:07:34 +02:00
dswbx f231353228 bump version to 0.17.2-rc.1 2025-09-15 09:31:01 +02:00
dswbx d2de642c17 fix: update build config and improve e2e adapter handling
Added dynamic inclusion of dependencies as external packages and switched build platform to browser. Streamlined e2e adapter test setup, improved type safety, and standardized console output for better clarity.
2025-09-15 09:25:32 +02:00
dswbx 50c5383138 Merge pull request #257 from bknd-io/fix/postgres-0.17-updates
postgres: bump 0.17.1 and improve custom connection API
2025-09-14 17:10:32 +02:00
dswbx fff88a9237 Merge pull request #258 from bknd-io/fix/mcp-auth-and-insert
fix: handle numbered object conversion and update MCP tool URL
2025-09-14 17:09:55 +02:00
dswbx d768a80526 Merge pull request #256 from bknd-io/fix/docker-0.17
bump Dockerfile to use bknd 0.17.1
2025-09-14 17:09:34 +02:00
dswbx 0d79b19c66 Merge pull request #255 from bknd-io/fix/node-static-import
fix raw node execution by making sure import attributes stick on builds
2025-09-14 17:08:54 +02:00
dswbx fea2812688 fix: handle numbered object conversion and update MCP tool URL
Add `convertNumberedObjectToArray` utility for handling numbered object to array conversion, addressing MCP tool allOf behavior. Adjust MCP tool URL in configuration and ensure default inspect options in development environment. Minor improvement in role enumeration handling for auth.
2025-09-14 17:03:23 +02:00
dswbx 62368c691a postgres: bump 0.17.1 and improve custom connection API
Aligned connection constructors to include an explicit name parameter, updated documentation, and streamlined connection methods for consistency. Adjusted dependencies and cleaned unused references.
2025-09-14 16:01:37 +02:00
dswbx dd9617598f bump Dockerfile to use bknd 0.17.1
Updated the default bknd version to 0.17.1 and included the `jsonv-ts` dependency in the Dockerfile.
2025-09-14 14:07:56 +02:00
dswbx a7f6d45ca9 fix raw node execution by making sure import attributes stick on builds 2025-09-14 13:43:45 +02:00
dswbx 92656523ff Merge pull request #254 from quicujo/feat/validate-uuid
Add UUID validation utility
2025-09-14 13:19:57 +02:00
anton e6ef7d9ff4 Add UUID validation utility
Add uuidValidate function to validate UUID strings and check their version.
Supports validation for v4 and v7 UUIDs using the uuid library's validate
and version functions.
2025-09-08 05:06:42 +02:00
dswbx 210b22e307 Merge pull request #251 from bknd-io/fix/cf-adapter-exports
cloudflare: fix missing serve export
2025-09-05 09:18:36 +02:00
dswbx 734235c798 cloudflare: fix missing serve export 2025-09-05 09:17:08 +02:00
dswbx c9773d49a6 bump 0.17.1 2025-09-03 17:17:26 +02:00
dswbx c9fb497c2d Merge pull request #246 from bknd-io/fix/mobile-input-zoom
style: fix mobile zoom on input focus/blur
2025-09-03 17:03:14 +02:00
dswbx ccd4eb10cb Merge pull request #245 from bknd-io/fix/cf-instance-flash
cloudflare: fixing multiple instances competing with configuration state by always serving fresh
2025-09-03 17:03:03 +02:00
dswbx 847f75b725 docs: update cloudflare imports 2025-09-03 17:00:24 +02:00
dswbx 8b4bf0d94a style: fix mobile zoom on input focus/blur 2025-09-03 08:28:27 +02:00
dswbx bf521e2931 cloudflare: fixing multiple instances competing with configuration state by always serving fresh 2025-09-03 07:54:40 +02:00
dswbx 111b2ae364 docs: fix generated mcp tools page title 2025-08-30 17:31:22 +02:00
dswbx dc08d12ce6 docs & readme: fix demo 2025-08-30 16:21:46 +02:00
dswbx ce773007f0 readme: update badges 2025-08-30 15:05:46 +02:00
dswbx e78c271c2f readme: added mcp as feature 2025-08-30 15:03:46 +02:00
dswbx 7ee07adc70 v0.17.0 2025-08-30 14:20:25 +02:00
dswbx 53e5c6fd98 Merge pull request #234 from bknd-io/release/0.17
Release 0.17
2025-08-30 14:16:37 +02:00
dswbx 4591631d3c fix e2e tests by forcely setting to be used db 2025-08-30 14:11:08 +02:00
dswbx 5a27c41516 mcp: fix tests changing new default mcp endpoint 2025-08-30 14:08:20 +02:00
dswbx 24eefa5357 mcp: added path config, register at /api path by default to work with frameworks 2025-08-30 14:06:13 +02:00
dswbx d898018b49 mcp ui: add copy on history 2025-08-30 13:41:15 +02:00
dswbx 09fdde1c5b fix mcp auth, styling fixes 2025-08-30 13:31:48 +02:00
dswbx fb0dbb9a51 fix system mcp notification console 2025-08-30 11:19:26 +02:00
dswbx 81f578895e bump version, fix cloudflare export 2025-08-30 10:20:07 +02:00
dswbx 2de12674d9 docs: mcp (#243)
* added mcp documentation

* added mcp docs
2025-08-30 10:15:56 +02:00
dswbx 5ed1cf19b6 docs: plugins, cloudflare, sdk, elements, database (#240)
* docs: added plugins docs, updated cloudflare docs

* updated cli help text

* added `systemEntity` and added docs on how to work with system entities

* docs: added defaults to cloudflare image plugin

* docs: updated sdk and elements
2025-08-29 12:50:23 +02:00
dswbx 0b21157f92 Merge pull request #237 from bknd-io/docs/fix-nextjs-and-events
docs: fixed events, commented edge runtime for nextjs
2025-08-20 17:06:35 +02:00
dswbx cacb13e464 docs: fixed events, commented edge runtime for nextjs 2025-08-20 16:05:56 +02:00
88 changed files with 2726 additions and 1870 deletions
+1 -4
View File
@@ -1,10 +1,7 @@
{
"mcpServers": {
"bknd": {
"url": "http://localhost:3000/mcp",
"headers": {
"API_KEY": "value"
}
"url": "http://localhost:28623/api/system/mcp"
}
}
}
+5 -4
View File
@@ -3,7 +3,7 @@
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/public/assets/poster.png)
<p align="center" width="100%">
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema" target="_blank">
<a href="https://stackblitz.com/github/bknd-io/bknd-demo?hideExplorer=1&embed=1&view=preview&initialPath=%2Fdata%2Fschema" target="_blank">
<strong>⭐ Live Demo</strong>
</a>
</p>
@@ -19,7 +19,7 @@ bknd simplifies app development by providing a fully functional backend for data
**For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING]
> This project requires Node.js 22 or higher (because of `node:sqlite`).
> This project requires Node.js 22.13 or higher (because of `node:sqlite`).
>
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
@@ -27,8 +27,8 @@ bknd simplifies app development by providing a fully functional backend for data
## Size
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements&t=1)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui&t=1)
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
@@ -52,6 +52,7 @@ Creating digital products always requires developing both the backend (the logic
* using a React framework (Next.js, React Router, Astro)
* 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components
* 🛠️ MCP server, client and UI built-in to control your backend
## Structure
The package is mainly split into 4 parts, each serving a specific purpose:
+2
View File
@@ -15,6 +15,7 @@ describe("AppServer", () => {
},
mcp: {
enabled: false,
path: "/api/system/mcp",
},
});
}
@@ -36,6 +37,7 @@ describe("AppServer", () => {
},
mcp: {
enabled: false,
path: "/api/system/mcp",
},
});
}
+6 -1
View File
@@ -61,8 +61,11 @@ function delayTypes() {
watcher_timeout = setTimeout(buildTypes, 1000);
}
const dependencies = Object.keys(pkg.dependencies);
// collection of always-external packages
const external = [
...dependencies,
"bun:test",
"node:test",
"node:assert/strict",
@@ -86,10 +89,10 @@ async function buildApi() {
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
format: ["esm"],
splitting: false,
treeshake: true,
loader: {
".svg": "dataurl",
},
@@ -245,6 +248,8 @@ async function buildAdapters() {
// base adapter handles
tsup.build({
...baseConfig(""),
target: "esnext",
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
}),
+6 -6
View File
@@ -17,7 +17,7 @@ async function run(
});
// Read from stdout
const reader = proc.stdout.getReader();
const reader = (proc.stdout as ReadableStream).getReader();
const decoder = new TextDecoder();
// Function to read chunks
@@ -30,7 +30,7 @@ async function run(
const text = decoder.decode(value);
if (!resolveCalled) {
console.log(c.dim(text.replace(/\n$/, "")));
console.info(c.dim(text.replace(/\n$/, "")));
}
onChunk(
text,
@@ -189,21 +189,21 @@ const adapters = {
async function testAdapter(name: keyof typeof adapters) {
const config = adapters[name];
console.log("adapter", c.cyan(name));
console.info("adapter", c.cyan(name));
await config.clean();
const { proc, data } = await config.start();
console.log("proc:", proc.pid, "data:", c.cyan(data));
console.info("proc:", proc.pid, "data:", c.cyan(data));
//proc.kill();process.exit(0);
const add_env = "env" in config && config.env ? config.env : "";
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
console.log("DONE!");
console.info("DONE!");
while (!proc.killed) {
proc.kill("SIGINT");
await Bun.sleep(250);
console.log("Waiting for process to exit...");
console.info("Waiting for process to exit...");
}
}
+1
View File
@@ -7,6 +7,7 @@ async function generate() {
server: {
mcp: {
enabled: true,
path: "/mcp",
},
},
auth: {
+8 -8
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.16.1",
"version": "0.17.2",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -15,7 +15,7 @@
},
"packageManager": "bun@1.2.19",
"engines": {
"node": ">=22"
"node": ">=22.13"
},
"scripts": {
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
@@ -30,7 +30,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./",
"prepublishOnly": "bun run types && bun run test && bun run test:node && VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
@@ -41,9 +41,9 @@
"test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
"docs:build-assets": "bun internal/docs.build-assets.ts"
},
"license": "FSL-1.1-MIT",
@@ -65,12 +65,13 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.8.1",
"jsonv-ts": "0.8.2",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"picocolors": "^1.1.1",
"swr": "^2.3.3"
},
"devDependencies": {
@@ -108,7 +109,6 @@
"libsql-stateless-easy": "^1.8.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
+1 -1
View File
@@ -43,7 +43,7 @@ export type ApiOptions = {
} & (
| {
token?: string;
user?: TApiUser;
user?: TApiUser | null;
}
| {
request: Request;
+24 -2
View File
@@ -23,13 +23,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers";
import { Api, type ApiOptions } from "Api";
export type AppPluginConfig = {
/**
* The name of the plugin.
*/
name: string;
/**
* The schema of the plugin.
*/
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
/**
* Called before the app is built.
*/
beforeBuild?: () => MaybePromise<void>;
/**
* Called after the app is built.
*/
onBuilt?: () => MaybePromise<void>;
/**
* Called when the server is initialized.
*/
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
onFirstBoot?: () => MaybePromise<void>;
/**
* Called when the app is booted.
*/
onBoot?: () => MaybePromise<void>;
/**
* Called when the app is first booted.
*/
onFirstBoot?: () => MaybePromise<void>;
};
export type AppPlugin = (app: App) => AppPluginConfig;
@@ -272,9 +293,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
if (!this.mcp) {
throw new Error("MCP is not enabled");
}
const mcpPath = this.modules.get("server").config.mcp.path;
return new McpClient({
url: "http://localhost/mcp",
url: "http://localhost" + mcpPath,
fetch: this.server.request,
});
}
+2 -2
View File
@@ -53,7 +53,7 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:",
origin: "localhost",
} as any,
{ id },
{ force: false, id },
);
expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost");
@@ -69,7 +69,7 @@ export function adapterTestSuite<
};
test("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const fetcher = makeHandler(undefined, undefined, { force: false, id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
+6 -2
View File
@@ -11,7 +11,7 @@ type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
export async function createApp<Env = BunEnv>(
{ distPath, ...config }: BunBkndConfig<Env> = {},
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
@@ -20,7 +20,11 @@ export async function createApp<Env = BunEnv>(
return await createRuntimeApp(
{
serveStatic: serveStatic({ root }),
serveStatic:
_serveStatic ??
serveStatic({
root,
}),
...config,
},
args ?? (process.env as Env),
@@ -1,10 +1,9 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig, type CfMakeConfigArgs } from "./config";
import { makeConfig, type CloudflareContext } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -41,18 +40,19 @@ describe("cf adapter", () => {
expect(dynamicConfig.connection).toBeDefined();
});
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await makeApp(c, { env: a } as any, o);
return await createApp(c, { env: a } as any, o);
},
makeHandler: (c, a, o) => {
console.log("args", a);
return async (request: any) => {
const app = await makeApp(
const app = await createApp(
// needs a fallback, otherwise tries to launch D1
c ?? {
connection: { url: DB_URL },
},
a!,
a as any,
o,
);
return app.fetch(request);
@@ -3,10 +3,10 @@
import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import type { App, MaybePromise } from "bknd";
import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global {
namespace Cloudflare {
@@ -16,7 +16,6 @@ declare global {
export type CloudflareEnv = Cloudflare.Env;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache";
bindings?: (args: Env) => MaybePromise<{
kv?: KVNamespace;
db?: D1Database;
@@ -34,11 +33,31 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
registerMedia?: boolean | ((env: Env) => void);
};
export type Context<Env = CloudflareEnv> = {
request: Request;
env: Env;
ctx: ExecutionContext;
};
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Partial<CloudflareContext<Env>> = {},
opts: RuntimeOptions = {
// by default, require the app to be rebuilt every time
force: true,
},
) {
const appConfig = await makeConfig(
{
...config,
onBuilt: async (app) => {
if (ctx.ctx) {
registerAsyncsExecutionContext(app, ctx?.ctx);
}
await config.onBuilt?.(app);
},
},
ctx,
);
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts);
}
// compatiblity
export const getFresh = createApp;
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
@@ -77,23 +96,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
}
}
const context = { request, env, ctx } as Context<Env>;
const mode = config.mode ?? "warm";
let app: App;
switch (mode) {
case "fresh":
app = await getFresh(config, context, { force: true });
break;
case "warm":
app = await getFresh(config, context);
break;
case "cache":
app = await getCached(config, context);
break;
default:
throw new Error(`Unknown mode ${mode}`);
}
const context = { request, env, ctx } as CloudflareContext<Env>;
const app = await createApp(config, context);
return app.fetch(request, env, ctx);
},
+6 -6
View File
@@ -8,7 +8,7 @@ import { getBinding } from "./bindings";
import { d1Sqlite } from "./connection/D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import type { Context, ExecutionContext } from "hono";
import type { Context as HonoContext, ExecutionContext } from "hono";
import { $console } from "bknd/utils";
import { setCookie } from "hono/cookie";
@@ -22,10 +22,10 @@ export const constants = {
},
};
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
export type CloudflareContext<Env extends CloudflareEnv = CloudflareEnv> = {
env: Env;
ctx?: ExecutionContext;
request?: Request;
ctx: ExecutionContext;
request: Request;
};
function getCookieValue(cookies: string | null, name: string) {
@@ -67,7 +67,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
return undefined;
},
set: (c: Context, d1?: D1DatabaseSession) => {
set: (c: HonoContext, d1?: D1DatabaseSession) => {
if (!d1 || !config.d1?.session) return;
const session = d1.getBookmark();
@@ -91,7 +91,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
let media_registered: boolean = false;
export async function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
args?: Partial<CloudflareContext<Env>>,
) {
if (!media_registered && config.registerMedia !== false) {
if (typeof config.registerMedia === "function") {
+9 -5
View File
@@ -1,8 +1,12 @@
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached";
export {
getFresh,
createApp,
serve,
type CloudflareEnv,
type CloudflareBkndConfig,
} from "./cloudflare-workers.adapter";
export { d1Sqlite, type D1ConnectionConfig };
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
export {
@@ -12,10 +16,10 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export { constants, type CloudflareContext } from "./config";
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
export { devFsPlugin, devFsWrite } from "./vite";
export { devFsVitePlugin, devFsWrite } from "./vite";
// for compatibility with old code
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
@@ -1,53 +0,0 @@
import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, constants } from "../config";
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Context<Env>,
) {
const { env, ctx } = args;
const { kv } = await config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
const cachedConfig = await kv.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
async function saveConfig(__config: any) {
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
}
const app = await createRuntimeApp(
{
...makeConfig(config, args),
initialConfig,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx);
app.module.server.client.get(constants.cache_endpoint, async (c) => {
await kv.delete(key);
return c.json({ message: "Cache cleared" });
});
await config.onBuilt?.(app);
},
beforeBuild: async (app) => {
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync",
);
await config.beforeBuild?.(app);
},
},
args,
);
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
return app;
}
-29
View File
@@ -1,29 +0,0 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(await makeConfig(config, args), args?.env, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
return await makeApp(
{
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
await config.onBuilt?.(app);
},
},
ctx,
opts,
);
}
+3 -3
View File
@@ -6,13 +6,13 @@ import { resolve } from "node:path";
* Vite plugin that provides Node.js filesystem access during development
* by injecting a polyfill into the SSR environment
*/
export function devFsPlugin({
export function devFsVitePlugin({
verbose = false,
configFile = "bknd.config.ts",
}: {
verbose?: boolean;
configFile?: string;
}): Plugin {
} = {}): any {
let isDev = false;
let projectRoot = "";
@@ -115,7 +115,7 @@ if (typeof globalThis !== 'undefined') {
return polyfill + code;
}
},
};
} satisfies Plugin;
}
// Write function that uses the dev-fs polyfill injected by our Vite plugin
+9 -3
View File
@@ -83,8 +83,12 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
}
app = App.create(appConfig);
apps.set(id, app);
if (!opts?.force) {
apps.set(id, app);
}
}
return app;
}
@@ -169,7 +173,9 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
return async (c: Context, next: Next) => {
if (!files) {
const manifest =
opts?.manifest || ((await import("bknd/dist/manifest.json")).default as Manifest);
opts?.manifest ||
((await import("bknd/dist/manifest.json", { with: { type: "json" } }))
.default as Manifest);
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
}
@@ -177,7 +183,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
if (files.includes(path)) {
try {
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
assert: { type: "text" },
with: { type: "text" },
}).then((m) => m.default);
if (content) {
+1
View File
@@ -14,6 +14,7 @@ import { usersFields } from "./auth-entities";
import { Authenticator } from "./authenticate/Authenticator";
import { Role } from "./authorize/Role";
export type UsersFields = typeof AppAuth.usersFields;
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "bknd" {
interface Users extends AppEntity, UserFieldSchema {}
+2 -1
View File
@@ -221,6 +221,7 @@ export class AuthController extends Controller {
return user;
};
const roles = Object.keys(this.auth.config.roles ?? {});
mcp.tool(
// @todo: needs permission
"auth_user_create",
@@ -231,7 +232,7 @@ export class AuthController extends Controller {
password: s.string({ minLength: 8 }),
role: s
.string({
enum: Object.keys(this.auth.config.roles ?? {}),
enum: roles.length > 0 ? roles : undefined,
})
.optional(),
}),
+7
View File
@@ -473,3 +473,10 @@ export function deepFreeze<T extends object>(object: T): T {
return Object.freeze(object);
}
export function convertNumberedObjectToArray(obj: object): any[] | object {
if (Object.keys(obj).every((key) => Number.isInteger(Number(key)))) {
return Object.values(obj);
}
return obj;
}
+9 -3
View File
@@ -1,10 +1,16 @@
import { v4, v7 } from "uuid";
import { v4, v7, validate, version as uuidVersion } from "uuid";
// generates v4
export function uuid(): string {
return v4();
return v4();
}
// generates v7
export function uuidv7(): string {
return v7();
return v7();
}
// validate uuid
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
return validate(uuid) && uuidVersion(uuid) === version;
}
+18 -15
View File
@@ -1,7 +1,15 @@
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } from "bknd/utils";
import {
jsc,
s,
describeRoute,
schemaToSpec,
omitKeys,
pickKeys,
mcpTool,
convertNumberedObjectToArray,
} from "bknd/utils";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
import type { EntityManager, EntityData } from "data/entities";
@@ -37,14 +45,6 @@ export class DataController extends Controller {
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
const func = h;
// @ts-ignore
func.description = name;
return func;
}
// info
hono.get(
"/",
@@ -52,10 +52,7 @@ export class DataController extends Controller {
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
}),
(c) => c.json(this.em.toJSON()),
);
// sync endpoint
@@ -432,7 +429,13 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData | EntityData[];
const _body = (await c.req.json()) as EntityData | EntityData[];
// @todo: check on jsonv-ts how to handle this better
// temporary fix for numbered object to array
// this happens when the MCP tool uses the allOf function
// to transform all validation targets into a single object
const body = convertNumberedObjectToArray(_body);
if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body);
+3
View File
@@ -258,6 +258,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
// @todo: centralize and add tests
hydrate(entity_name: string, _data: EntityData[]) {
if (!Array.isArray(_data) || _data.length === 0) {
return [];
}
const entity = this.entity(entity_name);
const data: EntityData[] = [];
+15
View File
@@ -39,6 +39,9 @@ import {
type PolymorphicRelationConfig,
} from "data/relations";
import type { MediaFields } from "media/AppMedia";
import type { UsersFields } from "auth/AppAuth";
type Options<Config = any> = {
entity: { name: string; fields: Record<string, Field<any, any, any>> };
field_name: string;
@@ -199,6 +202,18 @@ export function entity<
return new Entity(name, _fields, config, type);
}
type SystemEntities = {
users: UsersFields;
media: MediaFields;
};
export function systemEntity<
E extends keyof SystemEntities,
Fields extends Record<string, Field<any, any, any>>,
>(name: E, fields: Fields) {
return entity<E, SystemEntities[E] & Fields>(name, fields as any);
}
export function relation<Local extends Entity>(local: Local) {
return {
manyToOne: <Foreign extends Entity>(foreign: Foreign, config?: ManyToOneRelationConfig) => {
+1
View File
@@ -157,6 +157,7 @@ export {
medium,
make,
entity,
systemEntity,
relation,
index,
em,
+1
View File
@@ -9,6 +9,7 @@ import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema
import { mediaFields } from "./media-entities";
import * as MediaPermissions from "media/media-permissions";
export type MediaFields = typeof AppMedia.mediaFields;
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
declare module "bknd" {
interface Media extends AppEntity, MediaFieldSchema {}
+1 -1
View File
@@ -51,7 +51,7 @@ export class Controller {
protected getEntitiesEnum(em: EntityManager<any>): s.StringSchema {
const entities = em.entities.map((e) => e.name);
return entities.length > 0 ? s.string({ enum: entities }) : s.string();
return entities.length > 0 ? s.anyOf([s.string({ enum: entities }), s.string()]) : s.string();
}
registerMcp(): void {}
+2 -10
View File
@@ -1,13 +1,5 @@
import {
mark,
stripMark,
$console,
s,
objectEach,
transformObject,
McpServer,
DebugLogger,
} from "bknd/utils";
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils";
import { DebugLogger } from "core/utils/DebugLogger";
import { Guard } from "auth/authorize/Guard";
import { env } from "core/env";
import { BkndError } from "core/errors";
+9 -5
View File
@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
import type { TApiUser } from "Api";
import type { Manifest } from "vite";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -32,6 +33,7 @@ export type AdminControllerOptions = {
debugRerenders?: boolean;
theme?: "dark" | "light" | "system";
logoReturnPath?: string;
manifest?: Manifest;
};
export class AdminController extends Controller {
@@ -194,8 +196,10 @@ export class AdminController extends Controller {
};
if (isProd) {
let manifest: any;
if (this.options.assetsPath.startsWith("http")) {
let manifest: Manifest;
if (this.options.manifest) {
manifest = this.options.manifest;
} else if (this.options.assetsPath.startsWith("http")) {
manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", {
headers: {
Accept: "application/json",
@@ -204,14 +208,14 @@ export class AdminController extends Controller {
} else {
// @ts-ignore
manifest = await import("bknd/dist/manifest.json", {
assert: { type: "json" },
with: { type: "json" },
}).then((res) => res.default);
}
try {
// @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
assets.js = manifest["src/ui/main.tsx"]?.file!;
assets.css = manifest["src/ui/main.tsx"]?.css?.[0] as any;
} catch (e) {
$console.warn("Couldn't find assets in manifest", e);
}
+1
View File
@@ -24,6 +24,7 @@ export const serverConfigSchema = $object(
}),
mcp: s.strictObject({
enabled: s.boolean({ default: false }),
path: s.string({ default: "/api/system/mcp" }),
}),
},
{
+5 -3
View File
@@ -60,8 +60,9 @@ export class SystemController extends Controller {
register(app: App) {
app.server.route("/api/system", this.getController());
const config = app.modules.get("server").config;
if (!this.app.modules.get("server").config.mcp.enabled) {
if (!config.mcp.enabled) {
return;
}
@@ -84,7 +85,7 @@ export class SystemController extends Controller {
const level = consoleMap[message.params.level];
if (!level) return;
$console[level](message.params.message);
$console[level]("MCP notification", message.params.message ?? message.params);
}
});
@@ -97,7 +98,7 @@ export class SystemController extends Controller {
explainEndpoint: true,
},
endpoint: {
path: "/mcp",
path: config.mcp.path as any,
// @ts-ignore
_init: isNode() ? { duplex: "half" } : {},
},
@@ -375,6 +376,7 @@ export class SystemController extends Controller {
}),
(c) =>
c.json({
id: this.app._id,
version: {
config: c.get("app")?.version(),
bknd: getVersion(),
@@ -16,25 +16,49 @@ const schema = s.partialObject({
metadata: s.string({ enum: ["copyright", "keep", "none"] }),
quality: s.number({ minimum: 1, maximum: 100 }),
});
type ImageOptimizationSchema = s.Static<typeof schema>;
export type CloudflareImageOptimizationSchema = s.Static<typeof schema>;
export type CloudflareImageOptimizationOptions = {
/**
* The url to access the image optimization plugin
* @default /api/plugin/image/optimize
*/
accessUrl?: string;
/**
* The path to resolve the image from
* @default /api/media/file
*/
resolvePath?: string;
/**
* Whether to explain the image optimization schema
* @default false
*/
explain?: boolean;
defaultOptions?: ImageOptimizationSchema;
fixedOptions?: ImageOptimizationSchema;
/**
* The default options to use
* @default {}
*/
defaultOptions?: CloudflareImageOptimizationSchema;
/**
* The fixed options to use
* @default {}
*/
fixedOptions?: CloudflareImageOptimizationSchema;
/**
* The cache control to use
* @default public, max-age=31536000, immutable
*/
cacheControl?: string;
};
export function cloudflareImageOptimization({
accessUrl = "/_plugin/image/optimize",
accessUrl = "/api/plugin/image/optimize",
resolvePath = "/api/media/file",
explain = false,
defaultOptions = {},
fixedOptions = {},
}: CloudflareImageOptimizationOptions = {}): AppPlugin {
const disallowedAccessUrls = ["/api", "/admin", "/_optimize"];
const disallowedAccessUrls = ["/api", "/admin", "/api/plugin"];
if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) {
throw new Error(`Disallowed accessUrl: ${accessUrl}`);
}
+1
View File
@@ -1,5 +1,6 @@
export {
cloudflareImageOptimization,
type CloudflareImageOptimizationSchema,
type CloudflareImageOptimizationOptions,
} from "./cloudflare/image-optimization.plugin";
export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
+4 -3
View File
@@ -20,8 +20,9 @@ const style = {
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
container: "ml-[-10px]",
label: "text-primary/90 font-bold font-mono mr-2",
stringValue: "text-emerald-600 dark:text-emerald-500 font-mono select-text",
numberValue: "text-sky-500 dark:text-sky-400 font-mono",
stringValue:
"text-emerald-600 dark:text-emerald-500 font-mono select-text text-wrap whitespace-wrap break-words",
numberValue: "text-sky-500 dark:text-sky-400 font-mono select-text",
nullValue: "text-zinc-400 font-mono",
undefinedValue: "text-zinc-400 font-mono",
otherValue: "text-zinc-400 font-mono",
@@ -42,7 +43,7 @@ export const JsonViewer = ({
copyIconProps = {},
className,
}: JsonViewerProps) => {
const size = showSize ? (json === null ? 0 : JSON.stringify(json).length) : undefined;
const size = showSize ? (!json ? 0 : JSON.stringify(json).length) : undefined;
const formattedSize = formatNumber.fileSize(size ?? 0);
const showContext = size || title || showCopy;
+8
View File
@@ -13,6 +13,14 @@ const Warning = ({ className, ...props }: IconProps) => (
/>
);
const Err = ({ className, ...props }: IconProps) => (
<TbAlertCircle
{...props}
className={twMerge("dark:text-red-300 text-red-700 cursor-help", className)}
/>
);
export const Icon = {
Warning,
Err,
};
+50 -1
View File
@@ -46,24 +46,73 @@ export type DropzoneRenderProps = {
};
export type DropzoneProps = {
/**
* Get the upload info for a file
*/
getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string };
/**
* Handle the deletion of a file
*/
handleDelete: (file: { path: string }) => Promise<boolean>;
/**
* The initial items to display
*/
initialItems?: FileState[];
flow?: "start" | "end";
/**
* Maximum number of media items that can be uploaded
*/
maxItems?: number;
/**
* The allowed mime types
*/
allowedMimeTypes?: string[];
/**
* If true, the media item will be overwritten on entity media uploads if limit was reached
*/
overwrite?: boolean;
/**
* If true, the media items will be uploaded automatically
*/
autoUpload?: boolean;
/**
* Whether to add new items to the start or end of the list
* @default "start"
*/
flow?: "start" | "end";
/**
* The on rejected callback
*/
onRejected?: (files: FileWithPath[]) => void;
/**
* The on deleted callback
*/
onDeleted?: (file: { path: string }) => void;
/**
* The on uploaded all callback
*/
onUploadedAll?: (files: FileStateWithData[]) => void;
/**
* The on uploaded callback
*/
onUploaded?: (file: FileStateWithData) => void;
/**
* The on clicked callback
*/
onClick?: (file: FileState) => void;
/**
* The placeholder to use
*/
placeholder?: {
show?: boolean;
text?: string;
};
/**
* The footer to render
*/
footer?: ReactNode;
/**
* The children to render
*/
children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode);
};
@@ -10,15 +10,38 @@ import { mediaItemsToFileStates } from "./helper";
import { useInViewport } from "@mantine/hooks";
export type DropzoneContainerProps = {
/**
* The initial items to display
* @default []
*/
initialItems?: MediaFieldSchema[] | false;
/**
* Whether to use infinite scrolling
* @default false
*/
infinite?: boolean;
/**
* If given, the initial media items fetched will be from this entity
* @default undefined
*/
entity?: {
name: string;
id: PrimaryFieldType;
field: string;
};
/**
* The media config
* @default undefined
*/
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
/**
* Query to filter the media items
*/
query?: RepoQueryIn;
/**
* Whether to use a random filename
* @default false
*/
randomFilename?: boolean;
} & Omit<Partial<DropzoneProps>, "initialItems">;
+24 -6
View File
@@ -1,6 +1,6 @@
import { useClickOutside, useHotkeys } from "@mantine/hooks";
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
import { clampNumber } from "core/utils/numbers";
import { transformObject, clampNumber } from "bknd/utils";
import { throttle } from "lodash-es";
import { ScrollArea } from "radix-ui";
import {
@@ -19,9 +19,21 @@ import { appShellStore } from "ui/store";
import { useLocation } from "wouter";
export function Root({ children }: { children: React.ReactNode }) {
const sidebarWidths = appShellStore((store) => store.sidebars);
const style = transformObject(sidebarWidths, (value) => value.width);
return (
<AppShellProvider>
<div id="app-shell" data-shell="root" className="flex flex-1 flex-col select-none h-dvh">
<div
id="app-shell"
data-shell="root"
className="flex flex-1 flex-col select-none h-dvh"
style={Object.fromEntries(
Object.entries(style).map(([key, value]) => [
`--sidebar-width-${key}`,
`${value}px`,
]),
)}
>
{children}
</div>
</AppShellProvider>
@@ -218,6 +230,7 @@ const SidebarResize = ({
return (
<div
data-shell="sidebar-resize"
data-active={isResizing ? 1 : undefined}
className="w-px h-full hidden md:flex bg-muted after:transition-colors relative after:absolute after:inset-0 after:-left-px after:w-[2px] select-none data-[active]:after:bg-sky-400 data-[active]:cursor-col-resize hover:after:bg-sky-400 hover:cursor-col-resize after:z-2"
onMouseDown={handleMouseDown}
@@ -384,11 +397,16 @@ export function MaxHeightContainer(props: ComponentPropsWithoutRef<"div">) {
}
}
useEffect(updateHeaderHeight, []);
useEffect(() => {
updateHeaderHeight();
const resize = throttle(updateHeaderHeight, 500);
if (typeof window !== "undefined") {
window.addEventListener("resize", throttle(updateHeaderHeight, 500));
}
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
};
}, []);
return (
<div ref={scrollRef} style={{ height: `${height - offset}px` }} {...props}>
+5 -1
View File
@@ -74,7 +74,7 @@
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
::selection {
@apply bg-muted;
@apply bg-primary/15;
}
input {
@@ -122,3 +122,7 @@ body,
@utility debug-blue {
@apply border-blue-500 border;
}
@utility w-min-content {
width: min-content;
width: -webkit-min-content;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import * as React from "react";
import * as ReactDOM from "react-dom/client";
import Admin from "./Admin";
import "./main.css";
//import "./main.css";
import "./styles.css";
function render() {
+2 -2
View File
@@ -139,7 +139,7 @@ function Adapters() {
<span className="font-bold">Media Adapter:</span>
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
</Formy.Label>
<div className="flex flex-row gap-1 mb-2">
<div className="grid grid-cols-2 md:flex flex-row gap-1 mb-2 flex-wrap">
{ctx.schemas?.map((schema: any, i) => (
<Button
key={i}
@@ -165,7 +165,7 @@ function Adapters() {
</div>
{ctx.selected !== null && (
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
<Formy.Label as="legend" className="font-mono px-2">
<Formy.Label as="legend" className="font-mono px-2 w-min-content">
{autoFormatString(ctx.selectedSchema!.title!)}
</Formy.Label>
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
@@ -0,0 +1,16 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { useBknd } from "ui/client/bknd";
const clients = new Map<string, McpClient>();
export function getClient(opts: McpClientConfig) {
if (!clients.has(JSON.stringify(opts))) {
clients.set(JSON.stringify(opts), new McpClient(opts));
}
return clients.get(JSON.stringify(opts))!;
}
export function useMcpClient() {
const { config } = useBknd();
return getClient({ url: window.location.origin + config.server.mcp.path });
}
+5 -1
View File
@@ -7,13 +7,17 @@ import { useBknd } from "ui/client/bknd";
import { Empty } from "ui/components/display/Empty";
import { Button } from "ui/components/buttons/Button";
import { appShellStore } from "ui/store";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
export default function ToolsMcp() {
useBrowserTitle(["MCP UI"]);
const { config, options } = useBknd();
const feature = useMcpStore((state) => state.feature);
const setFeature = useMcpStore((state) => state.setFeature);
const content = useMcpStore((state) => state.content);
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
const mcpPath = config.server.mcp.path;
if (!config.server.mcp.enabled) {
return (
@@ -36,7 +40,7 @@ export default function ToolsMcp() {
<TbWorld />
<div className="min-w-0 flex-1">
<span className="block truncate text-sm font-mono leading-none">
{window.location.origin + "/mcp"}
{window.location.origin + mcpPath}
</span>
</div>
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export const useMcpStore = create(
content: null as ToolJson | null,
history: [] as { type: "request" | "response"; data: any }[],
historyLimit: 50,
historyVisible: true,
historyVisible: false,
},
(set) => ({
setTools: (tools: ToolJson[]) => set({ tools }),
+96 -81
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { getClient, getTemplate } from "./utils";
import { getTemplate } from "./utils";
import { useMcpStore } from "./state";
import { AppShell } from "ui/layouts/AppShell";
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
@@ -10,9 +10,11 @@ import { Field, Form } from "ui/components/form/json-schema-form";
import { Button } from "ui/components/buttons/Button";
import * as Formy from "ui/components/form/Formy";
import { appShellStore } from "ui/store";
import { Icon } from "ui/components/display/Icon";
import { useMcpClient } from "./hooks/use-mcp-client";
export function Sidebar({ open, toggle }) {
const client = getClient();
const client = useMcpClient();
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
const tools = useMcpStore((state) => state.tools);
const setTools = useMcpStore((state) => state.setTools);
@@ -20,11 +22,18 @@ export function Sidebar({ open, toggle }) {
const content = useMcpStore((state) => state.content);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const handleRefresh = useCallback(async () => {
setLoading(true);
const res = await client.listTools();
if (res) setTools(res.tools);
setError(null);
try {
const res = await client.listTools();
if (res) setTools(res.tools);
} catch (e) {
console.error(e);
setError(String(e));
}
setLoading(false);
}, []);
@@ -39,6 +48,7 @@ export function Sidebar({ open, toggle }) {
toggle={toggle}
renderHeaderRight={() => (
<div className="flex flex-row gap-2 items-center">
{error && <Icon.Err title={error} className="size-5 pointer-events-auto" />}
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
{tools.length}
</span>
@@ -88,7 +98,7 @@ export function Content() {
const [result, setResult] = useState<object | null>(null);
const historyVisible = useMcpStore((state) => state.historyVisible);
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
const client = getClient();
const client = useMcpClient();
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
const hasInputSchema =
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
@@ -128,83 +138,87 @@ export function Content() {
} catch (e) {}
return (
<div className="flex flex-grow flex-col max-w-screen">
<Form
key={content.name}
schema={{
title: "InputSchema",
...content?.inputSchema,
}}
validateOn="submit"
initialValues={payload}
hiddenSubmit={false}
onChange={(value) => {
setPayload(value);
}}
onSubmit={handleSubmit}
>
<AppShell.SectionHeader
className="max-w-full min-w-0"
right={
<div className="flex flex-row gap-2">
<IconButton
Icon={historyVisible ? TbHistory : TbHistoryOff}
onClick={() => setHistoryVisible(!historyVisible)}
/>
<Button
type="submit"
disabled={!content?.name || isPending}
variant="primary"
className="whitespace-nowrap"
>
Call Tool
</Button>
</div>
}
>
<AppShell.SectionHeaderTitle className="leading-tight">
<span className="opacity-50">
Tools <span className="opacity-70">/</span>
</span>{" "}
<span className="truncate">{content?.name}</span>
</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<div className="flex flex-grow flex-row w-vw">
<div className="flex flex-grow flex-col w-full">
<AppShell.Scrollable>
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
<p className="text-primary/80">{content?.description}</p>
{hasInputSchema && <Field name="" />}
<JsonViewerTabs
ref={jsonViewerTabsRef}
expand={9}
showCopy
showSize
tabs={{
Arguments: {
json: payload,
title: "Payload",
enabled: hasInputSchema,
},
Result: { json: readableResult, title: "Result" },
Configuration: {
json: content ?? null,
title: "Configuration",
},
}}
/>
</div>
</AppShell.Scrollable>
<Form
className="flex flex-grow flex-col min-w-0 max-w-screen"
key={content.name}
schema={{
title: "InputSchema",
...content?.inputSchema,
}}
validateOn="submit"
initialValues={payload}
hiddenSubmit={false}
onChange={(value) => {
setPayload(value);
}}
onSubmit={handleSubmit}
>
<AppShell.SectionHeader
className="max-w-full min-w-0"
right={
<div className="flex flex-row gap-2">
<IconButton
Icon={historyVisible ? TbHistory : TbHistoryOff}
onClick={() => setHistoryVisible(!historyVisible)}
/>
<Button
type="submit"
disabled={!content?.name || isPending}
variant="primary"
className="whitespace-nowrap"
>
Call Tool
</Button>
</div>
{historyVisible && (
<AppShell.Sidebar name="right" handle="left" maxWidth={window.innerWidth * 0.4}>
<History />
</AppShell.Sidebar>
)}
}
>
<AppShell.SectionHeaderTitle className="leading-tight">
<span className="opacity-50">
Tools <span className="opacity-70">/</span>
</span>{" "}
<span className="truncate">{content?.name}</span>
</AppShell.SectionHeaderTitle>
</AppShell.SectionHeader>
<div className="flex flex-grow flex-row w-vw">
<div
className="flex flex-grow flex-col max-w-full"
style={{
width: "calc(100% - var(--sidebar-width-right) - 1px)",
}}
>
<AppShell.Scrollable>
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
<p className="text-primary/80">{content?.description}</p>
{hasInputSchema && <Field name="" />}
<JsonViewerTabs
ref={jsonViewerTabsRef}
expand={9}
showCopy
showSize
tabs={{
Arguments: {
json: payload,
title: "Payload",
enabled: hasInputSchema,
},
Result: { json: readableResult, title: "Result" },
Configuration: {
json: content ?? null,
title: "Configuration",
},
}}
/>
</div>
</AppShell.Scrollable>
</div>
</Form>
</div>
{historyVisible && (
<AppShell.Sidebar name="right" handle="left" maxWidth={window.innerWidth * 0.4}>
<History />
</AppShell.Sidebar>
)}
</div>
</Form>
);
}
@@ -220,8 +234,9 @@ const History = () => {
<JsonViewer
key={`${item.type}-${i}`}
json={item.data}
title={item.type}
title={item.type.substring(0, 3)}
expand={2}
showCopy
/>
))}
</div>
-12
View File
@@ -1,17 +1,5 @@
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
import { Draft2019 } from "json-schema-library";
const clients = new Map<string, McpClient>();
export function getClient(
{ url, ...opts }: McpClientConfig = { url: window.location.origin + "/mcp" },
) {
if (!clients.has(String(url))) {
clients.set(String(url), new McpClient({ url, ...opts }));
}
return clients.get(String(url))!;
}
export function getTemplate(schema: object) {
if (!schema || schema === undefined || schema === null) return undefined;
+4 -2
View File
@@ -8,8 +8,10 @@ html.fixed,
html.fixed body {
top: 0;
left: 0;
height: 100%;
width: 100%;
right: 0;
bottom: 0;
height: 100dvh;
width: 100dvw;
position: fixed;
overflow: hidden;
overscroll-behavior-x: contain;
+3
View File
@@ -9,6 +9,9 @@ import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
import { $console } from "core/utils/console";
import { createClient } from "@libsql/client";
import util from "node:util";
util.inspect.defaultOptions.depth = 5;
registries.media.register("local", StorageLocalAdapter);
+5 -12
View File
@@ -15,7 +15,7 @@
},
"app": {
"name": "bknd",
"version": "0.16.1",
"version": "0.17.1",
"bin": "./dist/cli/index.js",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
@@ -35,7 +35,7 @@
"hono": "4.8.3",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.8.1",
"jsonv-ts": "0.8.2",
"kysely": "0.27.6",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
@@ -151,7 +151,6 @@
"bknd": "workspace:*",
"kysely-neon": "^1.3.0",
"tsup": "^8.4.0",
"typescript": "^5.8.2",
},
"optionalDependencies": {
"kysely": "^0.27.6",
@@ -1232,7 +1231,7 @@
"@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="],
"@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="],
"@types/bun": ["@types/bun@1.2.21", "", { "dependencies": { "bun-types": "1.2.21" } }, "sha512-NiDnvEqmbfQ6dmZ3EeUO577s4P5bf4HCTXtI6trMc6f6RzirY5IrF3aIookuSpyslFzrnvv2lmEWv5HyC1X79A=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -2516,7 +2515,7 @@
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
"jsonv-ts": ["jsonv-ts@0.8.1", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-kqt1OHZ4WM92PDAxySZeGGzccZr6q5YdKpM8c7QWkwGoaa1azwTG5lV9SN3PT4kVgI0OYFDr3OGkgCszLQ+WPw=="],
"jsonv-ts": ["jsonv-ts@0.8.2", "", { "optionalDependencies": { "hono": "*" }, "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-1Z7+maCfoGGqBPu5vN8rU9gIsW7OatYmn+STBTPkybbtNqeMzAoJDDrXHjsZ89x5dPH9W+OgMpNLtN0ouwiMYg=="],
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
@@ -3832,10 +3831,6 @@
"@bknd/plasmic/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@bknd/postgres/@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
"@bknd/postgres/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@bknd/sqlocal/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"@bundled-es-modules/cookie/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
@@ -4078,7 +4073,7 @@
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@types/bun/bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="],
"@types/bun/bun-types": ["bun-types@1.2.21", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-sa2Tj77Ijc/NTLS0/Odjq/qngmEPZfbfnOERi0KRUYhT9R8M4VBioWVmMWE5GrYbKMc+5lVybXygLdibHaqVqw=="],
"@typescript-eslint/experimental-utils/eslint-utils": ["eslint-utils@2.1.0", "", { "dependencies": { "eslint-visitor-keys": "^1.1.0" } }, "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg=="],
@@ -4684,8 +4679,6 @@
"@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/postgres/@types/bun/bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
+3 -3
View File
@@ -5,7 +5,7 @@ WORKDIR /app
# define bknd version to be used as:
# `docker build --build-arg VERSION=<version> -t bknd .`
ARG VERSION=0.13.0
ARG VERSION=0.17.1
# Install & copy required cli
RUN npm install --omit=dev bknd@${VERSION}
@@ -16,10 +16,10 @@ FROM node:24-alpine
WORKDIR /app
# Install pm2 and libsql
# Install required dependencies
RUN npm install -g pm2
RUN echo '{"type":"module"}' > package.json
RUN npm install @libsql/client
RUN npm install jsonv-ts @libsql/client
# Create volume and init args
VOLUME /data
+65 -67
View File
@@ -3,79 +3,77 @@
import * as React from "react";
export const examples = {
adminRich: {
path: "github/bknd-io/bknd-examples",
startScript: "example-admin-rich",
initialPath: "/data/schema"
}
adminRich: {
path: "github/bknd-io/bknd-examples",
startScript: "example-admin-rich",
initialPath: "/data/schema",
},
};
export const StackBlitz = ({
path,
ratio = 9 / 16,
example,
...props
path,
ratio = 9 / 16,
example,
...props
}: {
path?: string;
ratio?: number;
example?: keyof typeof examples;
[key: string]: unknown;
path?: string;
ratio?: number;
example?: keyof typeof examples;
[key: string]: unknown;
}) => {
const selected = example ? examples[example] : undefined;
const finalPath = path || selected?.path || "github/bknd-io/bknd-examples";
const selected = example ? examples[example] : undefined;
const finalPath = path || selected?.path || "github/bknd-io/bknd-examples";
const params = new URLSearchParams({
ctl: "1",
hideExplorer: "1",
embed: "1",
view: "preview",
...(selected || {}),
...props
});
const params = new URLSearchParams({
ctl: "1",
hideExplorer: "1",
embed: "1",
view: "preview",
...(selected || {}),
...props,
});
const url = new URL(
`https://stackblitz.com/${finalPath}?${params.toString()}`
);
const url = new URL(`https://stackblitz.com/${finalPath}?${params.toString()}`);
return (
<>
<div
style={{
position: "relative",
overflow: "hidden",
width: "100%",
paddingTop: `${ratio * 100}%`
}}
>
<iframe
width="100%"
height="100%"
src={url.toString()}
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
border: "none"
}}
/>
</div>
<div
style={{
fontSize: "80%",
opacity: 0.7,
marginTop: "0.2rem",
marginBottom: "1rem",
textAlign: "center"
}}
>
If you&rsquo;re having issues viewing it inline,{" "}
<a href={url.toString()} target="_blank" rel="noreferrer">
try in a new tab
</a>
.
</div>
</>
);
return (
<>
<div
style={{
position: "relative",
overflow: "hidden",
width: "100%",
paddingTop: `${ratio * 100}%`,
}}
>
<iframe
width="100%"
height="100%"
src={url.toString()}
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
border: "none",
}}
/>
</div>
<div
style={{
fontSize: "80%",
opacity: 0.7,
marginTop: "0.2rem",
marginBottom: "1rem",
textAlign: "center",
}}
>
If you&rsquo;re having issues viewing it inline,{" "}
<a href={url.toString()} target="_blank" rel="noreferrer">
try in a new tab
</a>
.
</div>
</>
);
};
+25 -2
View File
@@ -21,6 +21,29 @@ export function McpTool({ tool }: { tool: ReturnType<Tool["toJSON"]> }) {
);
}
const getType = (value: JSONSchemaDefinition) => {
if (value === undefined || value === null) {
return "any";
}
if (value.type) {
if (Array.isArray(value.type)) {
return value.type.join(" | ");
}
return value.type;
}
if ("anyOf" in value) {
return value.anyOf.map(getType).join(" | ");
}
if ("oneOf" in value) {
return value.oneOf.map(getType).join(" | ");
}
return "any";
};
export function JsonSchemaTypeTable({ schema }: { schema: JSONSchemaDefinition }) {
const properties = schema.properties ?? {};
const required = schema.required ?? [];
@@ -44,8 +67,8 @@ export function JsonSchemaTypeTable({ schema }: { schema: JSONSchemaDefinition }
typeDescription: (
<DynamicCodeBlock lang="json" code={indent(getTypeDescription(value), 1)} />
),
type: value.type,
default: value.default ? JSON.stringify(value.default) : undefined,
type: getType(value),
default: value.default !== undefined ? JSON.stringify(value.default) : undefined,
required: required.includes(key),
},
]),
+16
View File
@@ -0,0 +1,16 @@
import { cn } from "@/lib/cn";
import type { HTMLAttributes } from "react";
export function Wrapper(props: HTMLAttributes<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
"rounded-lg bg-radial-[at_bottom] from-fd-primary/10 p-4 border bg-origin-border border-fd-accent-primary/10 prose-no-margin dark:bg-black/20",
props.className,
)}
>
{props.children}
</div>
);
}
@@ -29,15 +29,15 @@ const app = createApp();
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
// ^? AppRequest
console.log("Request received", event.request.url);
console.log("Request received", event.params.request.url);
});
app.emgr.on("app-request", async (event) => {
console.log("Request received", event.request.url);
console.log("Request received", event.params.request.url);
});
app.emgr.onAny(async (event) => {
console.log("Event received", event.slug);
app.emgr.onAny(async (event, name) => {
console.log("Event received", event, name);
});
```
@@ -49,7 +49,7 @@ import { AppEvents } from "bknd";
export default {
onBuilt: (app) => {
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
console.log("Request received", event.request.url);
console.log("Request received", event.params.request.url);
});
},
};
@@ -63,7 +63,7 @@ By default, listeners are registered as `async` listeners, meaning that the list
app.emgr.onEvent(
AppEvents.AppRequest,
async (event) => {
console.log("Request received", event.request.url);
console.log("Request received", event.params.request.url);
},
{ mode: "sync" },
);
@@ -0,0 +1,211 @@
---
title: Plugins
tags: ["documentation"]
---
import { TypeTable } from 'fumadocs-ui/components/type-table';
bknd allows you to extend its functionality by creating plugins. These allows to hook into the app lifecycle and to provide a data structure that is guaranteed to be merged. A plugin is a function that takes in an instance of `App` and returns the following structure:
<AutoTypeTable path="../app/src/App.ts" name="AppPluginConfig" />
## Creating a simple plugin
To create a simple plugin which guarantees an entity `pages` to be available and an additioanl endpoint to render a html list of pages, you can create it as follows:
```tsx title="myPagesPlugin.tsx"
/** @jsxImportSource hono/jsx */
import { type App, type AppPlugin, em, entity, text } from "bknd";
export const myPagesPlugin: AppPlugin = (app) => ({
name: "my-pages-plugin",
// define the schema of the plugin
// this will always be merged into the app's schema
schema: () => em({
pages: entity("pages", {
title: text(),
content: text(),
}),
}),
// execute code after the app is built
onBuilt: () => {
// register a new endpoint, make sure that you choose an endpoint that is reachable for bknd
app.server.get("/my-pages", async (c) => {
const { data: pages } = await app.em.repo("pages").findMany({});
return c.html(
<body>
<h1>Pages: {pages.length}</h1>
<ul>
{pages.map((page: any) => (
<li key={page.id}>{page.title}</li>
))}
</ul>
</body>,
);
});
},
});
```
And then register it in your `bknd.config.ts` file:
```typescript
import type { BkndConfig } from "bknd/adapter";
import { myPagesPlugin } from "./myPagesPlugin";
export default {
options: {
plugins: [myPagesPlugin],
}
} satisfies BkndConfig;
```
The schema returned from the plugin will be merged into the schema of the app.
## Built-in plugins
bknd comes with a few built-in plugins that you can use.
### `syncTypes`
A simple plugin that writes down the TypeScript types of the data schema on boot and each build. The output is equivalent to running `npx bknd types`.
```typescript title="bknd.config.ts"
import { syncTypes } from "bknd/plugins";
import { writeFile } from "node:fs/promises";
export default {
options: {
plugins: [
syncTypes({
// whether to enable the plugin, make sure to disable in production
enabled: true,
// your writing function (required)
write: async (et) => {
await writeFile("bknd-types.d.ts", et.toString(), "utf-8");
}
}),
]
},
} satisfies BkndConfig;
```
### `syncConfig`
A simple plugin that writes down the app configuration on boot and each build.
```typescript title="bknd.config.ts"
import { syncConfig } from "bknd/plugins";
import { writeFile } from "node:fs/promises";
export default {
options: {
plugins: [
syncConfig({
// whether to enable the plugin, make sure to disable in production
enabled: true,
// your writing function (required)
write: async (config) => {
await writeFile("config.json", JSON.stringify(config, null, 2), "utf-8");
},
}),
]
},
} satisfies BkndConfig;
```
### `showRoutes`
A simple plugin that logs the routes of your app in the console.
```typescript title="bknd.config.ts"
import { showRoutes } from "bknd/plugins";
export default {
options: {
plugins: [
showRoutes({
// whether to show the routes only once (on first build)
once: true
})
],
},
} satisfies BkndConfig;
```
### `cloudflareImageOptimization`
A plugin that add Cloudflare Image Optimization to your app's media storage.
```typescript title="bknd.config.ts"
import { cloudflareImageOptimization } from "bknd/plugins";
export default {
options: {
plugins: [
cloudflareImageOptimization({
// the url to access the image optimization plugin
accessUrl: "/api/plugin/image/optimize",
// the path to resolve the image from, defaults to `/api/media/file`
resolvePath: "/api/media/file",
// for example, you may want to have default option to limit to a width of 1000px
defaultOptions: {
width: 1000,
}
})
],
},
} satisfies BkndConfig;
```
Here is a break down of all configuration options:
<AutoTypeTable path="../app/src/plugins/cloudflare/image-optimization.plugin.ts" name="CloudflareImageOptimizationOptions" />
When enabled, you can now access your images at your configured `accessUrl`. For example, if you have a media file at `/api/media/file/image.jpg`, you can access the optimized image at `/api/plugin/image/optimize/image.jpg` for optimization.
Now you can add query parameters for the transformations, e.g. `?width=1000&height=1000`.
<TypeTable
type={{
dpr: {
description:
'The device pixel ratio to use',
type: 'number',
default: 1,
},
fit: {
description: 'The fit mode to use',
type: '"scale-down" | "contain" | "cover" | "crop" | "pad"',
},
format: {
description: 'The format to use',
type: '"auto" | "avif" | "webp" | "jpeg" | "baseline-jpeg" | "json"'
},
height: {
description: 'The height to use',
type: 'number',
},
width: {
description: 'The width to use',
type: 'number',
},
metadata: {
description: 'The metadata to use',
type: '"copyright" | "keep" | "none"',
default: 'copyright'
},
quality: {
description: 'The quality to use',
type: 'number',
default: 85
},
}}
/>
@@ -108,7 +108,7 @@ import { config } from "@/bknd";
import { serve } from "bknd/adapter/nextjs";
// optionally, you can set the runtime to edge for better performance
export const runtime = "edge";
// export const runtime = "edge";
const handler = serve({
...config,
@@ -42,8 +42,7 @@ bun add bknd
## Serve the API
If you don't choose anything specific, the following code will use the `warm` mode and uses the first D1 binding it finds. See the
chapter [Using a different mode](#using-a-different-mode) for available modes.
If you don't choose anything specific, it uses the first D1 binding it finds.
```ts title="src/index.ts"
import { serve, d1 } from "bknd/adapter/cloudflare";
@@ -123,125 +122,13 @@ import { serve } from "bknd/adapter/cloudflare";
export default serve<Env>({
// ...
onBuilt: async (app) => {
// [!code highlight]
app.server.get("/hello", (c) => c.json({ hello: "world" })); // [!code highlight]
}, // [!code highlight]
},
});
```
The property `app.server` is a [Hono](https://hono.dev/) instance, you can literally anything you can do with Hono.
## Using a different mode
With the Cloudflare Workers adapter, you're being offered to 4 modes to choose from (default:
`warm`):
| Mode | Description | Use Case |
| :-------- | :----------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------- |
| `fresh` | On every request, the configuration gets refetched, app built and then served. | Ideal if you don't want to deal with eviction, KV or Durable Objects. |
| `warm` | It tries to keep the built app in memory for as long as possible, and rebuilds if evicted. | Better response times, should be the default choice. |
| `cache` | The configuration is fetched from KV to reduce the initial roundtrip to the database. | Generally faster response times with irregular access patterns. |
| `durable` | The bknd app is ran inside a Durable Object and can be configured to stay alive. | Slowest boot time, but fastest responses. Can be kept alive for as long as you want, giving similar response times as server instances. |
### Modes: `fresh` and `warm`
To use either `fresh` or `warm`, all you have to do is adding the desired mode to `cloudflare.
mode`, like so:
```ts
import { serve } from "bknd/adapter/cloudflare";
export default serve({
// ...
mode: "fresh", // mode: "fresh" | "warm" | "cache" | "durable"
});
```
### Mode: `cache`
For the cache mode to work, you also need to specify the KV to be used. For this, use the
`bindings` property:
```ts
import { serve } from "bknd/adapter/cloudflare";
export default serve<Env>({
// ...
mode: "cache",
bindings: ({ env }) => ({ kv: env.KV }),
});
```
### Mode: `durable` (advanced)
To use the `durable` mode, you have to specify the Durable Object to extract from your
environment, and additionally export the `DurableBkndApp` class:
```ts
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
export { DurableBkndApp };
export default serve<Env>({
// ...
mode: "durable",
bindings: ({ env }) => ({ dobj: env.DOBJ }),
keepAliveSeconds: 60, // optional
});
```
Next, you need to define the Durable Object in your `wrangler.toml` file (refer to the [Durable
Objects](https://developers.cloudflare.com/durable-objects/) documentation):
```toml
[[durable_objects.bindings]]
name = "DOBJ"
class_name = "DurableBkndApp"
[[migrations]]
tag = "v1"
new_classes = ["DurableBkndApp"]
```
Since the communication between the Worker and Durable Object is serialized, the `onBuilt`
property won't work. To use it (e.g. to specify special routes), you need to extend from the
`DurableBkndApp`:
```ts
import type { App } from "bknd";
import { serve, DurableBkndApp } from "bknd/adapter/cloudflare";
export default serve({
// ...
mode: "durable",
bindings: ({ env }) => ({ dobj: env.DOBJ }),
keepAliveSeconds: 60, // optional
});
export class CustomDurableBkndApp extends DurableBkndApp {
async onBuilt(app: App) {
app.modules.server.get("/custom/endpoint", (c) => c.text("Custom"));
}
}
```
In case you've already deployed your Worker, the deploy command may complain about a new class
being used. To fix this issue, you need to add a "rename migration":
```toml
[[durable_objects.bindings]]
name = "DOBJ"
class_name = "CustomDurableBkndApp"
[[migrations]]
tag = "v1"
new_classes = ["DurableBkndApp"]
[[migrations]]
tag = "v2"
renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}]
deleted_classes = ["DurableBkndApp"]
```
## D1 Sessions (experimental)
D1 now supports to enable [global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). This allows to reduce latency by reading from the closest region. In order for this to work, D1 has to be started from a bookmark. You can enable this behavior on bknd by setting the `d1.session` property:
@@ -250,9 +137,6 @@ D1 now supports to enable [global read replication](https://developers.cloudflar
import { serve } from "bknd/adapter/cloudflare";
export default serve({
// currently recommended to use "fresh" mode
// otherwise consecutive requests will use the same bookmark
mode: "fresh",
// ...
d1: {
// enables D1 sessions
@@ -272,3 +156,83 @@ If bknd is used in a stateful user context (like in a browser), it'll automatica
```bash
curl -H "x-cf-d1-session: <bookmark>" ...
```
## Filesystem access with Vite Plugin
The [Cloudflare Vite Plugin](https://developers.cloudflare.com/workers/vite-plugin/) allows to use Vite with Miniflare to emulate the Cloudflare Workers runtime. This is great, however, `unenv` disables any Node.js APIs that aren't supported, including the `fs` module. If you want to use plugins such as [`syncTypes`](/extending/plugins#synctypes), this will cause issues.
To fix this, bknd exports a Vite plugin that provides filesystem access during development. You can use it by adding the following to your `vite.config.ts` file:
```ts
import { devFsVitePlugin } from "bknd/adapter/cloudflare";
export default defineConfig({
plugins: [devFsVitePlugin()], // [!code highlight]
});
```
Now to use this polyfill, you can use the `devFsWrite` function to write files to the filesystem.
```ts
import { devFsWrite } from "bknd/adapter/cloudflare"; // [!code highlight]
import { syncTypes } from "bknd/plugins";
export default {
options: {
plugins: [
syncTypes({
write: async (et) => {
await devFsWrite("bknd-types.d.ts", et.toString()); // [!code highlight]
}
}),
]
},
} satisfies BkndConfig;
```
## Cloudflare Bindings in CLI
The bknd CLI does not automatically have access to the Cloudflare bindings. We need to manually proxy them to the CLI by using the `withPlatformProxy` helper function:
```typescript title="bknd.config.ts"
import { d1 } from "bknd/adapter/cloudflare";
import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy";
export default withPlatformProxy({
app: ({ env }) => ({
connection: d1({ binding: env.DB }),
}),
});
```
Now you can use the CLI with your Cloudflare resources.
<Callout type="warning">
Make sure to not import from this file in your app, as this would include `wrangler` as a dependency.
</Callout>
Instead, it's recommended to split this configuration into separate files, e.g. `bknd.config.ts` and `config.ts`:
```typescript title="config.ts"
import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare";
export default {
app: ({ env }) => ({
connection: d1({ binding: env.DB }),
}),
} satisfies CloudflareBkndConfig;
```
`config.ts` now holds the configuration, and can safely be imported in your app. Since the CLI looks for a `bknd.config.ts` file by default, we change it to wrap the configuration from `config.ts` in the `withPlatformProxy` helper function.
```typescript title="bknd.config.ts"
import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy";
import config from "./config";
export default withPlatformProxy(config);
```
As an additional safe guard, you have to set a `PROXY` environment variable to `1` to enable the proxy.
```bash
PROXY=1 npx bknd types
```
@@ -15,9 +15,11 @@
"./usage/sdk",
"./usage/react",
"./usage/elements",
"./usage/mcp/",
"---Extending---",
"./extending/config",
"./extending/events",
"./extending/plugins",
"---Integration---",
"./integration/introduction",
"./integration/(frameworks)/",
@@ -1,3 +0,0 @@
{
"pages": ["overview", "mcp"]
}
+1 -1
View File
@@ -19,7 +19,7 @@ Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature
Here is a preview of **bknd** in StackBlitz:
<StackBlitz {...examples.adminRich} />
<StackBlitz path="github/bknd-io/bknd-demo" initialPath="/" />
<Accordions>
<Accordion title="What's going on?">
+12 -10
View File
@@ -16,22 +16,24 @@ Here is the output:
$ npx bknd
Usage: bknd [options] [command]
⚡ bknd cli v0.16.0
⚡ bknd cli v0.17.0
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
user <action> create/update users, or generate a token (auth)
types [options] generate types
schema [options] get schema
run [options] run an instance
debug <subject> debug bknd
create [options] create a new project
copy-assets [options] copy static assets
config [options] get default config
help [command] display help for command
config [options] get app config
copy-assets [options] copy static assets
create [options] create a new project
debug <subject> debug bknd
mcp [options] mcp server stdio transport
run [options] run an instance
schema [options] get schema
sync [options] sync database
types [options] generate types
user [options] <action> create/update users, or generate a token (auth)
help [command] display help for command
```
## Starting an instance (`run`)
@@ -224,7 +224,7 @@ Example using `@neondatabase/serverless`:
import { createCustomPostgresConnection } from "@bknd/postgres";
import { NeonDialect } from "kysely-neon";
const neon = createCustomPostgresConnection(NeonDialect);
const neon = createCustomPostgresConnection("neon", NeonDialect);
serve({
connection: neon({
@@ -247,7 +247,7 @@ const xata = new client({
branch: process.env.XATA_BRANCH,
});
const xataConnection = createCustomPostgresConnection(XataDialect, {
const xataConnection = createCustomPostgresConnection("xata", XataDialect, {
supports: {
batching: false,
},
@@ -349,6 +349,91 @@ Note that we didn't add relational fields directly to the entity, but instead de
manually.
</Callout>
### System entities
There are multiple system entities which are added depending on if the module is enabled:
- `users`: if authentication is enabled
- `media`: if media is enabled and an adapter is configured
You can add additional fields to these entities. System-defined fields don't have to be repeated, those are automatically added to the entity, so don't worry about that. It's important though to match the system entities name, otherwise a new unrelated entity will be created.
If you'd like to connect your entities to system entities, you need them in the schema to access their reference when making relations. From the example above, if you'd like to connect the `posts` entity to the `users` entity, you can do so like this:
```typescript
import { em, entity, text, number, systemEntity } from "bknd";
const schema = em(
{
posts: entity("posts", {
title: text().required(),
slug: text().required(),
content: text(),
views: number(),
// don't add the foreign key field, it's automatically added
}),
comments: entity("comments", {
content: text(),
}),
// [!code highlight]
// add a `users` entity
users: systemEntity("users", { // [!code highlight]
// [!code highlight]
// optionally add additional fields
}) // [!code highlight]
},
// now you have access to the system entity "users"
({ relation, index }, { posts, comments, users }) => {
// ... other relations
relation(posts).manyToOne(users); // [!code highlight]
},
);
```
### Add media to an entity
If media is enabled, you can upload media directly or associate it with an entity. E.g. you may want to upload a cover image for a post, but also a gallery of images. Since a relation to the media entity is polymorphic, you have to:
1. add a virtual field to your entity (single `medium` or multiple `media`)
2. add the relation from the owning entity to the media entity
3. specify the mapped field name by using the `mappedBy` option
```typescript
import { em, entity, text, number, systemEntity, medium, media } from "bknd";
const schema = em(
{
posts: entity("posts", {
title: text().required(),
slug: text().required(),
content: text(),
views: number(),
// [!code highlight]
// `medium` represents a single media item
cover: medium(), // [!code highlight]
// [!code highlight]
// `media` represents a list of media items
gallery: media(), // [!code highlight]
}),
comments: entity("comments", {
content: text(),
}),
// [!code highlight]
// add the `media` entity
media: systemEntity("media", { // [!code highlight]
// [!code highlight]
// optionally add additional fields
}) // [!code highlight]
},
// now you have access to the system entity "media"
({ relation, index }, { posts, comments, media }) => {
// add the `cover` relation
relation(posts).polyToOne(media, { mappedBy: "cover" }); // [!code highlight]
// add the `gallery` relation
relation(posts).polyToMany(media, { mappedBy: "gallery" }); // [!code highlight]
},
);
```
### Type completion
To get type completion, there are two options:
@@ -44,16 +44,8 @@ export default function UserAvatar() {
#### Props
- `initialItems?: xMediaFieldSchema[]`: Initial items to display, must be an array of media objects.
- `entity?: { name: string; id: number; field: string }`: If given, the initial media items fetched will be from this entity.
- `query?: RepoQueryIn`: Query to filter the media items.
- `overwrite?: boolean`: If true, the media item will be overwritten on entity media uploads if limit was reached.
- `maxItems?: number`: Maximum number of media items that can be uploaded.
- `autoUpload?: boolean`: If true, the media items will be uploaded automatically.
- `onRejected?: (files: FileWithPath[]) => void`: Callback when a file is rejected.
- `onDeleted?: (file: FileState) => void`: Callback when a file is deleted.
- `onUploaded?: (file: FileState) => void`: Callback when a file is uploaded.
- `placeholder?: { show?: boolean; text?: string }`: Placeholder text to show when no media items are present.
<AutoTypeTable path="../app/src/ui/elements/media/DropzoneContainer.tsx" name="DropzoneContainerProps" />
#### Customize Rendering
@@ -0,0 +1,4 @@
{
"title": "MCP",
"pages": ["overview", "tools-resources"]
}
@@ -0,0 +1,208 @@
---
title: "Overview"
description: "Built-in full featured MCP server."
tags: ["documentation"]
---
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
<Callout type="warning">
The MCP server is currently experimental and may change in the future. During this period, it is disabled by default. To stabilize it, and make **bknd MCP native**, all configuration changes you can make today with the integrated Admin UI will be migrated to use the MCP server.
</Callout>
bknd includes a fully featured MCP server that can be used to interact with the bknd instance. It uses a lightweight MCP implementation that works in any environment bknd works in. Unlike other MCP servers, the exposed tools and resources are mainly dynamically generated from the schema, extracted from defined hono routes, and manually defined ones. This means exposed tools and resources are always up to date, and requires little overhead to maintain.
- Fully featured, always up to date MCP server natively integrated with bknd
- Integrated MCP UI accessible from the Admin UI
- Built-in MCP client directly usable from your app instance
- CLI command to run an MCP server on stdio transport
## Integrated MCP UI
<video controls poster="/content/mcp/v0.17_mcp_o_frame_12.5s.jpg">
<source src="/content/mcp/v0.17_mcp_o.mp4" type="video/mp4" />
</video>
Once enabled, you can access the MCP UI at `/mcp`, or choose "MCP" from the top right user menu.
## Enable MCP
If you're using `initialConfig`, you can enable the MCP server by setting the `server.mcp.enabled` property to `true`.
```typescript
import type { BkndConfig } from "bknd";
export default {
initialConfig: {
server: {
mcp: {
enabled: true,
}
}
}
} satisfies BkndConfig;
```
Using the Admin UI, you can either navigate to `/settings/server` or click top right on the user menu, select "Settings", then "Server". Enable the MCP server by checking the "Enabled" checkbox under "Mcp".
## Using the MCP Client
The implementation is closely following the [MCP spec 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18) powered by [jsonv-ts](https://github.com/jsonv-ts/jsonv-ts), therefore any spec compliant client will work. However, there is a built-in MCP client:
```typescript
import { McpClient } from "bknd/utils";
const client = new McpClient({
url: "http://localhost:1337/api/system/mcp",
});
```
Alternatively, similar to the `getApi` function, you can use the `getMcpClient` function to get the client from your app instance that doesn't travel through the network.
```typescript
import { createApp } from "bknd";
const app = createApp();
const client = app.getMcpClient();
```
Unlike the official spec requires, there is no initialization required, but supported. Without connecting, initialization or fetching the list of tools, you can directly call them. For example, you could fetch a list of `posts`:
```typescript
const result = await client.callTool({
name: "data_entity_read_many",
arguments: {
entity: "posts",
limit: 3,
select: ["id", "title"],
},
});
// {
// data: [
// { id: 1, title: "Post 1" },
// { id: 2, title: "Post 2" },
// { id: 3, title: "Post 3" }
// ],
// }
```
Refer to the [jsonv-ts docs](https://github.com/dswbx/jsonv-ts#mcp-client) for more information.
## STDIO Transport
To start an MCP server on stdio transport, you can use the `mcp` CLI command. This is useful when you want to use it with IDEs or other tools that support stdio transport.
```bash
npx bknd mcp
```
If you want have the Streamable HTTP endpoint disabled, you can still use the STDIO transport by passing the `--force` option.
```bash
npx bknd mcp --force
```
## Usage in external tools
You can also use the MCP server in external tools, such as VS Code, Cursor, or other IDEs that support MCP. This list is not exhaustive, and will be updated.
<Accordions type="single">
<Accordion title="Cursor">
Go to: `Settings` -> `Cursor Settings` -> `Tools & Integrations` -> `Add a custom MCP server`
Pasting the following config into your Cursor `~/.cursor/mcp.json` file is the recommended approach. You can also install in a specific project by creating `.cursor/mcp.json` in your project folder. See [Cursor MCP docs](https://docs.cursor.com/context/model-context-protocol) for more info.
```json tab="Local"
{
"mcpServers": {
"bknd": {
"command": "npx",
"args": ["-y", "bknd@latest", "mcp"]
}
}
}
```
```json tab="Remote"
{
"mcpServers": {
"bknd": {
"url": "http://localhost:1337/api/system/mcp"
}
}
}
```
</Accordion>
<Accordion title="VS Code">
Add this to your VS Code MCP config. See [VS Code MCP docs](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more info.
```json tab="Local"
{
"servers": {
"bknd": {
"type": "stdio",
"command": "npx",
"args": ["-y", "bknd@latest", "mcp"]
}
}
}
```
```json tab="Remote"
{
"servers": {
"bknd": {
"type": "http",
"url": "http://localhost:1337/api/system/mcp"
}
}
}
```
</Accordion>
<Accordion title="Claude Desktop">
Add this to your Claude Desktop `claude_desktop_config.json` file. See [Claude Desktop MCP docs](https://modelcontextprotocol.io/quickstart/user) for more info.
```json title="claude_desktop_config.json"
{
"mcpServers": {
"bknd": {
"command": "npx",
"args": ["-y", "bknd@latest", "mcp"]
}
}
}
```
</Accordion>
</Accordions>
If a tool you're using is not listed here, please let us know by [opening an issue](https://github.com/bknd-dev/bknd/issues/new) or [contacting us on Discord](https://discord.com/invite/Qjz9nNHYTB).
## Authentication
Both the Streamable HTTP and STDIO transport support authentication. The same authentication mechanism as the API is used, so permissions work the exact same way.
When using the Streamable HTTP transport, you can pass the `Authorization` header to the client.
```typescript
const client = new McpClient({
url: "http://localhost:1337/api/system/mcp",
headers: {
Authorization: `Bearer ${token}`,
},
});
```
When using the STDIO transport, you can pass an `--token` option to the CLI command.
```bash
npx bknd mcp --token <token>
```
Alternatively, you can also use the `BEARER_TOKEN` environment variable.
```bash
BEARER_TOKEN=<token> npx bknd mcp
```
@@ -199,6 +199,37 @@ To delete many records of an entity, use the `deleteMany` method:
const { data } = await api.data.deleteMany("posts", { views: { $lte: 1 } });
```
### `data.readManyByReference([entity], [id], [reference], [query])`
To retrieve records from a related entity by following a reference, use the `readManyByReference` method:
```ts
const { data } = await api.data.readManyByReference("posts", 1, "comments", {
limit: 5,
sort: "-created_at",
});
```
### `data.count([entity], [where])`
To count records in an entity that match certain criteria, use the `count` method:
```ts
const { data } = await api.data.count("posts", {
views: { $gt: 100 }
});
```
### `data.exists([entity], [where])`
To check if any records exist in an entity that match certain criteria, use the `exists` method:
```ts
const { data } = await api.data.exists("posts", {
title: "Hello, World!"
});
```
## Auth (`api.auth`)
Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the
@@ -241,3 +272,89 @@ To retrieve the current user, use the `me` method:
```ts
const { data } = await api.auth.me();
```
## Media (`api.media`)
Access the `Media` specific API methods at `api.media`.
### `media.listFiles()`
To retrieve a list of all uploaded files, use the `listFiles` method:
```ts
const { data } = await api.media.listFiles();
// ^? FileListObject[]
```
### `media.getFile([filename])`
To retrieve a file as a readable stream, use the `getFile` method:
```ts
const { data } = await api.media.getFile("image.jpg");
// ^? ReadableStream<Uint8Array>
```
### `media.getFileStream([filename])`
To get a file stream directly, use the `getFileStream` method:
```ts
const stream = await api.media.getFileStream("image.jpg");
// ^? ReadableStream<Uint8Array>
```
### `media.download([filename])`
To download a file as a File object, use the `download` method:
```ts
const file = await api.media.download("image.jpg");
// ^? File
```
### `media.upload([item], [options])`
To upload a file, use the `upload` method. The item can be any of:
- `File` object
- `Request` object
- `Response` object
- `string` (URL)
- `ReadableStream`
- `Buffer`
- `Blob`
```ts
// Upload a File object
const { data } = await api.media.upload(item);
// Upload from a URL
const { data } = await api.media.upload("https://example.com/image.jpg");
// Upload with custom options
const { data } = await api.media.upload(item, {
filename: "custom-name.jpg",
});
```
### `media.uploadToEntity([entity], [id], [field], [item], [options])`
To upload a file directly to an entity field, use the `uploadToEntity` method:
```ts
const { data } = await api.media.uploadToEntity(
"posts",
1,
"image",
item
);
```
### `media.deleteFile([filename])`
To delete a file, use the `deleteFile` method:
```ts
const { data } = await api.media.deleteFile("image.jpg");
```
+1081 -1004
View File
File diff suppressed because it is too large Load Diff
+26 -24
View File
@@ -5,11 +5,12 @@ import type { MDXComponents } from "mdx/types";
import * as FilesComponents from "fumadocs-ui/components/files";
import * as TabsComponents from "fumadocs-ui/components/tabs";
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
import {
CalloutInfo,
CalloutPositive,
CalloutCaution,
CalloutDanger,
CalloutInfo,
CalloutPositive,
CalloutCaution,
CalloutDanger,
} from "./app/_components/Callout";
import { StackBlitz } from "./app/_components/StackBlitz";
import { Icon } from "@iconify/react";
@@ -18,29 +19,30 @@ import * as Twoslash from "fumadocs-twoslash/ui";
import { createGenerator } from "fumadocs-typescript";
import { AutoTypeTable } from "fumadocs-typescript/ui";
import { Wrapper } from "@/components/misc/Wrapper";
const generator = createGenerator({
tsconfigPath: "../tsconfig.json",
tsconfigPath: "../tsconfig.json",
});
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
...defaultMdxComponents,
...TabsComponents,
...FilesComponents,
...Twoslash,
Accordion,
Accordions,
CalloutInfo,
CalloutPositive,
CalloutCaution,
CalloutDanger,
StackBlitz,
Icon,
APIPage: (props) => <APIPage {...openapi.getAPIPageProps(props)} />,
AutoTypeTable: (props) => (
<AutoTypeTable {...props} generator={generator} />
),
...components,
};
return {
...defaultMdxComponents,
...TabsComponents,
...FilesComponents,
...Twoslash,
Accordion,
Accordions,
CalloutInfo,
CalloutPositive,
CalloutCaution,
CalloutDanger,
StackBlitz,
Icon,
Wrapper,
img: (props) => <ImageZoom {...(props as any)} />,
APIPage: (props) => <APIPage {...openapi.getAPIPageProps(props)} />,
AutoTypeTable: (props) => <AutoTypeTable {...props} generator={generator} />,
...components,
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

+3 -9
View File
@@ -6,18 +6,12 @@ import { writeFile, readFile } from "node:fs/promises";
const config = {
mcpConfig: "./mcp.json",
outFile: "./content/docs/(documentation)/modules/server/mcp.mdx",
outFile: "./content/docs/(documentation)/usage/mcp/tools-resources.mdx",
};
async function generate() {
console.info("Generating MCP documentation...");
try {
console.log("bun version", Bun.version);
} catch (e) {
console.log("bun failed");
}
await cleanup();
const mcpConfig = JSON.parse(await readFile(config.mcpConfig, "utf-8"));
const document = await generateDocument(mcpConfig);
@@ -33,8 +27,8 @@ async function generateDocument({
resources: ReturnType<Resource["toJSON"]>[];
}) {
return `---
title: "MCP"
description: "Built-in full featured MCP server."
title: "Tools & Resources"
description: "Tools & Resources of the built-in full featured MCP server."
tags: ["documentation"]
---
import { JsonSchemaTypeTable } from '@/components/McpTool';
+1 -1
View File
@@ -5,7 +5,7 @@
* We're using separate files, so that "wrangler" doesn't get bundled with your worker.
*/
import { withPlatformProxy } from "bknd/adapter/cloudflare";
import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy";
import config from "./config.ts";
export default withPlatformProxy(config);
-14
View File
@@ -1,21 +1,7 @@
import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare";
import { syncTypes } from "bknd/plugins";
import { writeFile } from "node:fs/promises";
const isDev = !import.meta.env.PROD;
export default {
d1: {
session: true,
},
options: {
plugins: [
syncTypes({
enabled: isDev,
write: async (et) => {
await writeFile("bknd-types.d.ts", et.toString());
},
}),
],
},
} satisfies CloudflareBkndConfig;
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "node_modules/wrangler/config-schema.json",
"name": "bknd-cf-worker-example",
"main": "src/index.ts",
"compatibility_date": "2025-02-04",
"compatibility_date": "2025-08-03",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"minify": true,
+3 -3
View File
@@ -59,7 +59,7 @@ You can create a custom kysely postgres dialect by using the `createCustomPostgr
```ts
import { createCustomPostgresConnection } from "@bknd/postgres";
const connection = createCustomPostgresConnection(MyDialect)({
const connection = createCustomPostgresConnection("my_postgres_dialect", MyDialect)({
// your custom dialect configuration
supports: {
batching: true
@@ -75,7 +75,7 @@ const connection = createCustomPostgresConnection(MyDialect)({
import { createCustomPostgresConnection } from "@bknd/postgres";
import { NeonDialect } from "kysely-neon";
const connection = createCustomPostgresConnection(NeonDialect)({
const connection = createCustomPostgresConnection("neon", NeonDialect)({
connectionString: process.env.NEON,
});
```
@@ -94,7 +94,7 @@ const xata = new client({
branch: process.env.XATA_BRANCH,
});
const connection = createCustomPostgresConnection(XataDialect, {
const connection = createCustomPostgresConnection("xata", XataDialect, {
supports: {
batching: false,
},
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@bknd/postgres",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -31,8 +31,7 @@
"@xata.io/kysely": "^0.2.1",
"bknd": "workspace:*",
"kysely-neon": "^1.3.0",
"tsup": "^8.4.0",
"typescript": "^5.8.2"
"tsup": "^8.4.0"
},
"tsup": {
"entry": ["src/index.ts"],
@@ -1,12 +1,13 @@
import { Kysely, PostgresDialect } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "bknd/data";
import { customIntrospector } from "bknd";
import $pg from "pg";
export type PgPostgresConnectionConfig = $pg.PoolConfig;
export class PgPostgresConnection extends PostgresConnection {
override name = "pg";
private pool: $pg.Pool;
constructor(config: PgPostgresConnectionConfig) {
+13 -9
View File
@@ -1,4 +1,11 @@
import { Connection, type DbFunctions, type FieldSpec, type SchemaResponse } from "bknd/data";
import {
Connection,
type DbFunctions,
type FieldSpec,
type SchemaResponse,
type ConnQuery,
type ConnQueryResults,
} from "bknd";
import {
ParseJSONResultsPlugin,
type ColumnDataType,
@@ -13,12 +20,13 @@ export type QB = SelectQueryBuilder<any, any, any>;
export const plugins = [new ParseJSONResultsPlugin()];
export abstract class PostgresConnection<DB = any> extends Connection<DB> {
export abstract class PostgresConnection extends Connection {
protected override readonly supported = {
batching: true,
softscans: true,
};
constructor(kysely: Kysely<DB>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
constructor(kysely: Kysely<any>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
super(
kysely,
fn ?? {
@@ -73,13 +81,9 @@ export abstract class PostgresConnection<DB = any> extends Connection<DB> {
];
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
return this.kysely.transaction().execute(async (trx) => {
return Promise.all(queries.map((q) => trx.executeQuery(q).then((r) => r.rows)));
return Promise.all(qbs.map((q) => trx.executeQuery(q)));
}) as any;
}
}
@@ -1,5 +1,5 @@
import { type SchemaMetadata, sql } from "kysely";
import { BaseIntrospector } from "bknd/data";
import { BaseIntrospector } from "bknd";
type PostgresSchemaSpec = {
name: string;
@@ -1,13 +1,15 @@
import { Kysely } from "kysely";
import { PostgresIntrospector } from "./PostgresIntrospector";
import { PostgresConnection, plugins } from "./PostgresConnection";
import { customIntrospector } from "bknd/data";
import { customIntrospector } from "bknd";
import { PostgresJSDialect } from "kysely-postgres-js";
import $postgresJs, { type Sql, type Options, type PostgresType } from "postgres";
export type PostgresJsConfig = Options<Record<string, PostgresType>>;
export class PostgresJsConnection extends PostgresConnection {
override name = "postgres-js";
private postgres: Sql;
constructor(opts: { postgres: Sql }) {
+8 -5
View File
@@ -1,9 +1,10 @@
import type { Constructor } from "bknd/core";
import { customIntrospector, type DbFunctions } from "bknd/data";
import { customIntrospector, type DbFunctions } from "bknd";
import { Kysely, type Dialect, type KyselyPlugin } from "kysely";
import { plugins, PostgresConnection } from "./PostgresConnection";
import { PostgresIntrospector } from "./PostgresIntrospector";
export type Constructor<T> = new (...args: any[]) => T;
export type CustomPostgresConnection = {
supports?: PostgresConnection["supported"];
fn?: Partial<DbFunctions>;
@@ -15,17 +16,19 @@ export function createCustomPostgresConnection<
T extends Constructor<Dialect>,
C extends ConstructorParameters<T>[0],
>(
name: string,
dialect: Constructor<Dialect>,
options?: CustomPostgresConnection,
): (config: C) => PostgresConnection<any> {
): (config: C) => PostgresConnection {
const supported = {
batching: true,
...((options?.supports ?? {}) as any),
};
return (config: C) =>
new (class extends PostgresConnection<any> {
protected override readonly supported = supported;
new (class extends PostgresConnection {
override name = name;
override readonly supported = supported;
constructor(config: C) {
super(
+38 -17
View File
@@ -1,8 +1,11 @@
import { describe, beforeAll, afterAll, expect, it, afterEach } from "bun:test";
import type { PostgresConnection } from "../src";
import { createApp } from "bknd";
import * as proto from "bknd/data";
import { createApp, em, entity, text } from "bknd";
import { disableConsoleLog, enableConsoleLog } from "bknd/utils";
// @ts-ignore
import { connectionTestSuite } from "$bknd/data/connection/connection-test-suite";
// @ts-ignore
import { bunTestRunner } from "$bknd/adapter/bun/test";
export type TestSuiteConfig = {
createConnection: () => InstanceType<typeof PostgresConnection>;
@@ -12,8 +15,9 @@ export type TestSuiteConfig = {
export async function defaultCleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables & create new schema
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
@@ -32,6 +36,23 @@ export function testSuite(config: TestSuiteConfig) {
beforeAll(() => disableConsoleLog(["log", "warn", "error"]));
afterAll(() => enableConsoleLog());
// @todo: postgres seems to add multiple indexes, thus failing the test suite
/* describe("test suite", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => {
const connection = config.createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection, config);
await connection.close();
},
};
},
rawDialectDetails: [],
});
}); */
describe("base", () => {
it("should connect to the database", async () => {
const connection = config.createConnection();
@@ -73,14 +94,14 @@ export function testSuite(config: TestSuiteConfig) {
});
it("should create a basic schema", async () => {
const schema = proto.em(
const schema = em(
{
posts: proto.entity("posts", {
title: proto.text().required(),
content: proto.text(),
posts: entity("posts", {
title: text().required(),
content: text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
comments: entity("comments", {
content: text(),
}),
},
(fns, s) => {
@@ -153,20 +174,20 @@ export function testSuite(config: TestSuiteConfig) {
});
it("should support uuid", async () => {
const schema = proto.em(
const schema = em(
{
posts: proto.entity(
posts: entity(
"posts",
{
title: proto.text().required(),
content: proto.text(),
title: text().required(),
content: text(),
},
{
primary_format: "uuid",
},
),
comments: proto.entity("comments", {
content: proto.text(),
comments: entity("comments", {
content: text(),
}),
},
(fns, s) => {
@@ -187,8 +208,8 @@ export function testSuite(config: TestSuiteConfig) {
// @ts-expect-error
expect(config.data.entities?.posts.fields?.id.config?.format).toBe("uuid");
const em = app.em;
const mutator = em.mutator(em.entity("posts"));
const $em = app.em;
const mutator = $em.mutator($em.entity("posts"));
const data = await mutator.insertOne({ title: "Hello", content: "World" });
expect(data.data.id).toBeString();
expect(String(data.data.id).length).toBe(36);
+31 -27
View File
@@ -1,29 +1,33 @@
{
"compilerOptions": {
"composite": false,
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"target": "ES2022",
"noImplicitAny": false,
"allowJs": true,
"verbatimModuleSyntax": true,
"declaration": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules"]
"compilerOptions": {
"composite": false,
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"target": "ES2022",
"noImplicitAny": false,
"allowJs": true,
"verbatimModuleSyntax": true,
"declaration": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": false,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"$bknd/*": ["../../app/src/*"]
}
},
"include": ["./src/**/*.ts"],
"exclude": ["node_modules"]
}