mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f8d8d26ac | |||
| 655bb241a8 | |||
| 1ef5bf7d15 | |||
| 9a1589da0b | |||
| f231353228 | |||
| d2de642c17 | |||
| 50c5383138 | |||
| fff88a9237 | |||
| d768a80526 | |||
| 0d79b19c66 | |||
| fea2812688 | |||
| 62368c691a | |||
| dd9617598f | |||
| a7f6d45ca9 | |||
| 92656523ff | |||
| e6ef7d9ff4 | |||
| 210b22e307 | |||
| 734235c798 | |||
| c9773d49a6 | |||
| c9fb497c2d | |||
| ccd4eb10cb | |||
| 847f75b725 | |||
| 8b4bf0d94a | |||
| bf521e2931 | |||
| 111b2ae364 | |||
| dc08d12ce6 | |||
| ce773007f0 | |||
| e78c271c2f | |||
| 7ee07adc70 | |||
| 53e5c6fd98 | |||
| 4591631d3c | |||
| 5a27c41516 | |||
| 24eefa5357 | |||
| d898018b49 | |||
| 09fdde1c5b | |||
| fb0dbb9a51 | |||
| 81f578895e | |||
| 2de12674d9 | |||
| 5ed1cf19b6 | |||
| 0b21157f92 | |||
| cacb13e464 |
+1
-4
@@ -1,10 +1,7 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"bknd": {
|
"bknd": {
|
||||||
"url": "http://localhost:3000/mcp",
|
"url": "http://localhost:28623/api/system/mcp"
|
||||||
"headers": {
|
|
||||||
"API_KEY": "value"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||

|

|
||||||
|
|
||||||
<p align="center" width="100%">
|
<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>
|
<strong>⭐ Live Demo</strong>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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.**
|
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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
|
> Please keep in mind that **bknd** is still under active development
|
||||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
> 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
|
## Size
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
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.
|
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)
|
* using a React framework (Next.js, React Router, Astro)
|
||||||
* 📦 Official API and React SDK with type-safety
|
* 📦 Official API and React SDK with type-safety
|
||||||
* ⚛️ React elements for auto-configured authentication and media components
|
* ⚛️ React elements for auto-configured authentication and media components
|
||||||
|
* 🛠️ MCP server, client and UI built-in to control your backend
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
The package is mainly split into 4 parts, each serving a specific purpose:
|
The package is mainly split into 4 parts, each serving a specific purpose:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe("AppServer", () => {
|
|||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
path: "/api/system/mcp",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -36,6 +37,7 @@ describe("AppServer", () => {
|
|||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
path: "/api/system/mcp",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -61,8 +61,11 @@ function delayTypes() {
|
|||||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dependencies = Object.keys(pkg.dependencies);
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
const external = [
|
const external = [
|
||||||
|
...dependencies,
|
||||||
"bun:test",
|
"bun:test",
|
||||||
"node:test",
|
"node:test",
|
||||||
"node:assert/strict",
|
"node:assert/strict",
|
||||||
@@ -86,10 +89,10 @@ async function buildApi() {
|
|||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
external: [...external],
|
external: [...external],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
|
target: "esnext",
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
treeshake: true,
|
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
@@ -245,6 +248,8 @@ async function buildAdapters() {
|
|||||||
// base adapter handles
|
// base adapter handles
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig(""),
|
...baseConfig(""),
|
||||||
|
target: "esnext",
|
||||||
|
platform: "neutral",
|
||||||
entry: ["src/adapter/index.ts"],
|
entry: ["src/adapter/index.ts"],
|
||||||
outDir: "dist/adapter",
|
outDir: "dist/adapter",
|
||||||
}),
|
}),
|
||||||
|
|||||||
+6
-6
@@ -17,7 +17,7 @@ async function run(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Read from stdout
|
// Read from stdout
|
||||||
const reader = proc.stdout.getReader();
|
const reader = (proc.stdout as ReadableStream).getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
// Function to read chunks
|
// Function to read chunks
|
||||||
@@ -30,7 +30,7 @@ async function run(
|
|||||||
|
|
||||||
const text = decoder.decode(value);
|
const text = decoder.decode(value);
|
||||||
if (!resolveCalled) {
|
if (!resolveCalled) {
|
||||||
console.log(c.dim(text.replace(/\n$/, "")));
|
console.info(c.dim(text.replace(/\n$/, "")));
|
||||||
}
|
}
|
||||||
onChunk(
|
onChunk(
|
||||||
text,
|
text,
|
||||||
@@ -189,21 +189,21 @@ const adapters = {
|
|||||||
|
|
||||||
async function testAdapter(name: keyof typeof adapters) {
|
async function testAdapter(name: keyof typeof adapters) {
|
||||||
const config = adapters[name];
|
const config = adapters[name];
|
||||||
console.log("adapter", c.cyan(name));
|
console.info("adapter", c.cyan(name));
|
||||||
await config.clean();
|
await config.clean();
|
||||||
|
|
||||||
const { proc, data } = await config.start();
|
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);
|
//proc.kill();process.exit(0);
|
||||||
|
|
||||||
const add_env = "env" in config && config.env ? config.env : "";
|
const add_env = "env" in config && config.env ? config.env : "";
|
||||||
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
||||||
console.log("DONE!");
|
console.info("DONE!");
|
||||||
|
|
||||||
while (!proc.killed) {
|
while (!proc.killed) {
|
||||||
proc.kill("SIGINT");
|
proc.kill("SIGINT");
|
||||||
await Bun.sleep(250);
|
await Bun.sleep(250);
|
||||||
console.log("Waiting for process to exit...");
|
console.info("Waiting for process to exit...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ async function generate() {
|
|||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
path: "/mcp",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
|||||||
+8
-8
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"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.",
|
"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",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.19",
|
"packageManager": "bun@1.2.19",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.13"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
||||||
"updater": "bun x npm-check-updates -ui",
|
"updater": "bun x npm-check-updates -ui",
|
||||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
"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",
|
"postpublish": "rm -f README.md",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
"test:all": "bun run test && bun run test:node",
|
"test:all": "bun run test && bun run test:node",
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
"test:vitest:coverage": "vitest run --coverage",
|
"test:vitest:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
|
||||||
"test:e2e:debug": "playwright test --debug",
|
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
||||||
"test:e2e:report": "playwright show-report",
|
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||||
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
@@ -65,12 +65,13 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.8.1",
|
"jsonv-ts": "0.8.2",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"swr": "^2.3.3"
|
"swr": "^2.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -108,7 +109,6 @@
|
|||||||
"libsql-stateless-easy": "^1.8.0",
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
|||||||
+1
-1
@@ -43,7 +43,7 @@ export type ApiOptions = {
|
|||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
token?: string;
|
token?: string;
|
||||||
user?: TApiUser;
|
user?: TApiUser | null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
request: Request;
|
request: Request;
|
||||||
|
|||||||
+24
-2
@@ -23,13 +23,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
|||||||
import { Api, type ApiOptions } from "Api";
|
import { Api, type ApiOptions } from "Api";
|
||||||
|
|
||||||
export type AppPluginConfig = {
|
export type AppPluginConfig = {
|
||||||
|
/**
|
||||||
|
* The name of the plugin.
|
||||||
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* The schema of the plugin.
|
||||||
|
*/
|
||||||
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
|
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
|
||||||
|
/**
|
||||||
|
* Called before the app is built.
|
||||||
|
*/
|
||||||
beforeBuild?: () => MaybePromise<void>;
|
beforeBuild?: () => MaybePromise<void>;
|
||||||
|
/**
|
||||||
|
* Called after the app is built.
|
||||||
|
*/
|
||||||
onBuilt?: () => MaybePromise<void>;
|
onBuilt?: () => MaybePromise<void>;
|
||||||
|
/**
|
||||||
|
* Called when the server is initialized.
|
||||||
|
*/
|
||||||
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
|
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
|
||||||
onFirstBoot?: () => MaybePromise<void>;
|
/**
|
||||||
|
* Called when the app is booted.
|
||||||
|
*/
|
||||||
onBoot?: () => MaybePromise<void>;
|
onBoot?: () => MaybePromise<void>;
|
||||||
|
/**
|
||||||
|
* Called when the app is first booted.
|
||||||
|
*/
|
||||||
|
onFirstBoot?: () => MaybePromise<void>;
|
||||||
};
|
};
|
||||||
export type AppPlugin = (app: App) => AppPluginConfig;
|
export type AppPlugin = (app: App) => AppPluginConfig;
|
||||||
|
|
||||||
@@ -272,9 +293,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
if (!this.mcp) {
|
if (!this.mcp) {
|
||||||
throw new Error("MCP is not enabled");
|
throw new Error("MCP is not enabled");
|
||||||
}
|
}
|
||||||
|
const mcpPath = this.modules.get("server").config.mcp.path;
|
||||||
|
|
||||||
return new McpClient({
|
return new McpClient({
|
||||||
url: "http://localhost/mcp",
|
url: "http://localhost" + mcpPath,
|
||||||
fetch: this.server.request,
|
fetch: this.server.request,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function adapterTestSuite<
|
|||||||
url: overrides.dbUrl ?? ":memory:",
|
url: overrides.dbUrl ?? ":memory:",
|
||||||
origin: "localhost",
|
origin: "localhost",
|
||||||
} as any,
|
} as any,
|
||||||
{ id },
|
{ force: false, id },
|
||||||
);
|
);
|
||||||
expect(app).toBeDefined();
|
expect(app).toBeDefined();
|
||||||
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
||||||
@@ -69,7 +69,7 @@ export function adapterTestSuite<
|
|||||||
};
|
};
|
||||||
|
|
||||||
test("responds with the same app id", async () => {
|
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);
|
const { res, data } = await getConfig(fetcher);
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type BunEnv = Bun.Env;
|
|||||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||||
|
|
||||||
export async function createApp<Env = BunEnv>(
|
export async function createApp<Env = BunEnv>(
|
||||||
{ distPath, ...config }: BunBkndConfig<Env> = {},
|
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
@@ -20,7 +20,11 @@ export async function createApp<Env = BunEnv>(
|
|||||||
|
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
serveStatic: serveStatic({ root }),
|
serveStatic:
|
||||||
|
_serveStatic ??
|
||||||
|
serveStatic({
|
||||||
|
root,
|
||||||
|
}),
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
args ?? (process.env as Env),
|
args ?? (process.env as Env),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { makeApp } from "./modes/fresh";
|
import { makeConfig, type CloudflareContext } from "./config";
|
||||||
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter";
|
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -41,18 +40,19 @@ describe("cf adapter", () => {
|
|||||||
expect(dynamicConfig.connection).toBeDefined();
|
expect(dynamicConfig.connection).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
||||||
makeApp: async (c, a, o) => {
|
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) => {
|
makeHandler: (c, a, o) => {
|
||||||
|
console.log("args", a);
|
||||||
return async (request: any) => {
|
return async (request: any) => {
|
||||||
const app = await makeApp(
|
const app = await createApp(
|
||||||
// needs a fallback, otherwise tries to launch D1
|
// needs a fallback, otherwise tries to launch D1
|
||||||
c ?? {
|
c ?? {
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
a!,
|
a as any,
|
||||||
o,
|
o,
|
||||||
);
|
);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import type { RuntimeBkndConfig } from "bknd/adapter";
|
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import { getFresh } from "./modes/fresh";
|
import type { MaybePromise } from "bknd";
|
||||||
import { getCached } from "./modes/cached";
|
|
||||||
import type { App, MaybePromise } from "bknd";
|
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
|
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
|
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Cloudflare {
|
namespace Cloudflare {
|
||||||
@@ -16,7 +16,6 @@ declare global {
|
|||||||
|
|
||||||
export type CloudflareEnv = Cloudflare.Env;
|
export type CloudflareEnv = Cloudflare.Env;
|
||||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache";
|
|
||||||
bindings?: (args: Env) => MaybePromise<{
|
bindings?: (args: Env) => MaybePromise<{
|
||||||
kv?: KVNamespace;
|
kv?: KVNamespace;
|
||||||
db?: D1Database;
|
db?: D1Database;
|
||||||
@@ -34,11 +33,31 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
|||||||
registerMedia?: boolean | ((env: Env) => void);
|
registerMedia?: boolean | ((env: Env) => void);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Context<Env = CloudflareEnv> = {
|
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
request: Request;
|
config: CloudflareBkndConfig<Env>,
|
||||||
env: Env;
|
ctx: Partial<CloudflareContext<Env>> = {},
|
||||||
ctx: ExecutionContext;
|
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>(
|
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env> = {},
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
@@ -77,23 +96,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = { request, env, ctx } as Context<Env>;
|
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||||
const mode = config.mode ?? "warm";
|
const app = await createApp(config, context);
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getBinding } from "./bindings";
|
|||||||
import { d1Sqlite } from "./connection/D1Connection";
|
import { d1Sqlite } from "./connection/D1Connection";
|
||||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import type { Context, ExecutionContext } from "hono";
|
import type { Context as HonoContext, ExecutionContext } from "hono";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { setCookie } from "hono/cookie";
|
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;
|
env: Env;
|
||||||
ctx?: ExecutionContext;
|
ctx: ExecutionContext;
|
||||||
request?: Request;
|
request: Request;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getCookieValue(cookies: string | null, name: string) {
|
function getCookieValue(cookies: string | null, name: string) {
|
||||||
@@ -67,7 +67,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
|||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
set: (c: Context, d1?: D1DatabaseSession) => {
|
set: (c: HonoContext, d1?: D1DatabaseSession) => {
|
||||||
if (!d1 || !config.d1?.session) return;
|
if (!d1 || !config.d1?.session) return;
|
||||||
|
|
||||||
const session = d1.getBookmark();
|
const session = d1.getBookmark();
|
||||||
@@ -91,7 +91,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
|||||||
let media_registered: boolean = false;
|
let media_registered: boolean = false;
|
||||||
export async function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args?: CfMakeConfigArgs<Env>,
|
args?: Partial<CloudflareContext<Env>>,
|
||||||
) {
|
) {
|
||||||
if (!media_registered && config.registerMedia !== false) {
|
if (!media_registered && config.registerMedia !== false) {
|
||||||
if (typeof config.registerMedia === "function") {
|
if (typeof config.registerMedia === "function") {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
|
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
|
||||||
|
|
||||||
export * from "./cloudflare-workers.adapter";
|
export {
|
||||||
export { makeApp, getFresh } from "./modes/fresh";
|
getFresh,
|
||||||
export { getCached } from "./modes/cached";
|
createApp,
|
||||||
|
serve,
|
||||||
|
type CloudflareEnv,
|
||||||
|
type CloudflareBkndConfig,
|
||||||
|
} from "./cloudflare-workers.adapter";
|
||||||
export { d1Sqlite, type D1ConnectionConfig };
|
export { d1Sqlite, type D1ConnectionConfig };
|
||||||
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
|
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
|
||||||
export {
|
export {
|
||||||
@@ -12,10 +16,10 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
export { constants } from "./config";
|
export { constants, type CloudflareContext } from "./config";
|
||||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||||
export { registries } from "bknd";
|
export { registries } from "bknd";
|
||||||
export { devFsPlugin, devFsWrite } from "./vite";
|
export { devFsVitePlugin, devFsWrite } from "./vite";
|
||||||
|
|
||||||
// for compatibility with old code
|
// for compatibility with old code
|
||||||
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
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;
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,13 @@ import { resolve } from "node:path";
|
|||||||
* Vite plugin that provides Node.js filesystem access during development
|
* Vite plugin that provides Node.js filesystem access during development
|
||||||
* by injecting a polyfill into the SSR environment
|
* by injecting a polyfill into the SSR environment
|
||||||
*/
|
*/
|
||||||
export function devFsPlugin({
|
export function devFsVitePlugin({
|
||||||
verbose = false,
|
verbose = false,
|
||||||
configFile = "bknd.config.ts",
|
configFile = "bknd.config.ts",
|
||||||
}: {
|
}: {
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
configFile?: string;
|
configFile?: string;
|
||||||
}): Plugin {
|
} = {}): any {
|
||||||
let isDev = false;
|
let isDev = false;
|
||||||
let projectRoot = "";
|
let projectRoot = "";
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ if (typeof globalThis !== 'undefined') {
|
|||||||
return polyfill + code;
|
return polyfill + code;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
} satisfies Plugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write function that uses the dev-fs polyfill injected by our Vite plugin
|
// Write function that uses the dev-fs polyfill injected by our Vite plugin
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
}
|
}
|
||||||
|
|
||||||
app = App.create(appConfig);
|
app = App.create(appConfig);
|
||||||
|
|
||||||
|
if (!opts?.force) {
|
||||||
apps.set(id, app);
|
apps.set(id, app);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +173,9 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
|||||||
return async (c: Context, next: Next) => {
|
return async (c: Context, next: Next) => {
|
||||||
if (!files) {
|
if (!files) {
|
||||||
const manifest =
|
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 || [])]);
|
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +183,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
|||||||
if (files.includes(path)) {
|
if (files.includes(path)) {
|
||||||
try {
|
try {
|
||||||
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
||||||
assert: { type: "text" },
|
with: { type: "text" },
|
||||||
}).then((m) => m.default);
|
}).then((m) => m.default);
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { usersFields } from "./auth-entities";
|
|||||||
import { Authenticator } from "./authenticate/Authenticator";
|
import { Authenticator } from "./authenticate/Authenticator";
|
||||||
import { Role } from "./authorize/Role";
|
import { Role } from "./authorize/Role";
|
||||||
|
|
||||||
|
export type UsersFields = typeof AppAuth.usersFields;
|
||||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||||
declare module "bknd" {
|
declare module "bknd" {
|
||||||
interface Users extends AppEntity, UserFieldSchema {}
|
interface Users extends AppEntity, UserFieldSchema {}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export class AuthController extends Controller {
|
|||||||
return user;
|
return user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roles = Object.keys(this.auth.config.roles ?? {});
|
||||||
mcp.tool(
|
mcp.tool(
|
||||||
// @todo: needs permission
|
// @todo: needs permission
|
||||||
"auth_user_create",
|
"auth_user_create",
|
||||||
@@ -231,7 +232,7 @@ export class AuthController extends Controller {
|
|||||||
password: s.string({ minLength: 8 }),
|
password: s.string({ minLength: 8 }),
|
||||||
role: s
|
role: s
|
||||||
.string({
|
.string({
|
||||||
enum: Object.keys(this.auth.config.roles ?? {}),
|
enum: roles.length > 0 ? roles : undefined,
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -473,3 +473,10 @@ export function deepFreeze<T extends object>(object: T): T {
|
|||||||
|
|
||||||
return Object.freeze(object);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { v4, v7 } from "uuid";
|
import { v4, v7, validate, version as uuidVersion } from "uuid";
|
||||||
|
|
||||||
// generates v4
|
// generates v4
|
||||||
export function uuid(): string {
|
export function uuid(): string {
|
||||||
return v4();
|
return v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generates v7
|
||||||
export function uuidv7(): string {
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { Handler } from "hono/types";
|
|
||||||
import type { ModuleBuildContext } from "modules";
|
import type { ModuleBuildContext } from "modules";
|
||||||
import { Controller } from "modules/Controller";
|
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 * as SystemPermissions from "modules/permissions";
|
||||||
import type { AppDataConfig } from "../data-schema";
|
import type { AppDataConfig } from "../data-schema";
|
||||||
import type { EntityManager, EntityData } from "data/entities";
|
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 hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
|
||||||
const entitiesEnum = this.getEntitiesEnum(this.em);
|
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
|
// info
|
||||||
hono.get(
|
hono.get(
|
||||||
"/",
|
"/",
|
||||||
@@ -52,10 +52,7 @@ export class DataController extends Controller {
|
|||||||
summary: "Retrieve data configuration",
|
summary: "Retrieve data configuration",
|
||||||
tags: ["data"],
|
tags: ["data"],
|
||||||
}),
|
}),
|
||||||
handler("data info", (c) => {
|
(c) => c.json(this.em.toJSON()),
|
||||||
// sample implementation
|
|
||||||
return c.json(this.em.toJSON());
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// sync endpoint
|
// sync endpoint
|
||||||
@@ -432,7 +429,13 @@ export class DataController extends Controller {
|
|||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return this.notFound(c);
|
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)) {
|
if (Array.isArray(body)) {
|
||||||
const result = await this.em.mutator(entity).insertMany(body);
|
const result = await this.em.mutator(entity).insertMany(body);
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
|
|
||||||
// @todo: centralize and add tests
|
// @todo: centralize and add tests
|
||||||
hydrate(entity_name: string, _data: EntityData[]) {
|
hydrate(entity_name: string, _data: EntityData[]) {
|
||||||
|
if (!Array.isArray(_data) || _data.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const entity = this.entity(entity_name);
|
const entity = this.entity(entity_name);
|
||||||
const data: EntityData[] = [];
|
const data: EntityData[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
type PolymorphicRelationConfig,
|
type PolymorphicRelationConfig,
|
||||||
} from "data/relations";
|
} from "data/relations";
|
||||||
|
|
||||||
|
import type { MediaFields } from "media/AppMedia";
|
||||||
|
import type { UsersFields } from "auth/AppAuth";
|
||||||
|
|
||||||
type Options<Config = any> = {
|
type Options<Config = any> = {
|
||||||
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
entity: { name: string; fields: Record<string, Field<any, any, any>> };
|
||||||
field_name: string;
|
field_name: string;
|
||||||
@@ -199,6 +202,18 @@ export function entity<
|
|||||||
return new Entity(name, _fields, config, type);
|
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) {
|
export function relation<Local extends Entity>(local: Local) {
|
||||||
return {
|
return {
|
||||||
manyToOne: <Foreign extends Entity>(foreign: Foreign, config?: ManyToOneRelationConfig) => {
|
manyToOne: <Foreign extends Entity>(foreign: Foreign, config?: ManyToOneRelationConfig) => {
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export {
|
|||||||
medium,
|
medium,
|
||||||
make,
|
make,
|
||||||
entity,
|
entity,
|
||||||
|
systemEntity,
|
||||||
relation,
|
relation,
|
||||||
index,
|
index,
|
||||||
em,
|
em,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { buildMediaSchema, registry, type TAppMediaConfig } from "./media-schema
|
|||||||
import { mediaFields } from "./media-entities";
|
import { mediaFields } from "./media-entities";
|
||||||
import * as MediaPermissions from "media/media-permissions";
|
import * as MediaPermissions from "media/media-permissions";
|
||||||
|
|
||||||
|
export type MediaFields = typeof AppMedia.mediaFields;
|
||||||
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
export type MediaFieldSchema = FieldSchema<typeof AppMedia.mediaFields>;
|
||||||
declare module "bknd" {
|
declare module "bknd" {
|
||||||
interface Media extends AppEntity, MediaFieldSchema {}
|
interface Media extends AppEntity, MediaFieldSchema {}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class Controller {
|
|||||||
|
|
||||||
protected getEntitiesEnum(em: EntityManager<any>): s.StringSchema {
|
protected getEntitiesEnum(em: EntityManager<any>): s.StringSchema {
|
||||||
const entities = em.entities.map((e) => e.name);
|
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 {}
|
registerMcp(): void {}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import {
|
import { mark, stripMark, $console, s, objectEach, transformObject, McpServer } from "bknd/utils";
|
||||||
mark,
|
import { DebugLogger } from "core/utils/DebugLogger";
|
||||||
stripMark,
|
|
||||||
$console,
|
|
||||||
s,
|
|
||||||
objectEach,
|
|
||||||
transformObject,
|
|
||||||
McpServer,
|
|
||||||
DebugLogger,
|
|
||||||
} from "bknd/utils";
|
|
||||||
import { Guard } from "auth/authorize/Guard";
|
import { Guard } from "auth/authorize/Guard";
|
||||||
import { env } from "core/env";
|
import { env } from "core/env";
|
||||||
import { BkndError } from "core/errors";
|
import { BkndError } from "core/errors";
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
|
|||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import type { TApiUser } from "Api";
|
import type { TApiUser } from "Api";
|
||||||
|
import type { Manifest } from "vite";
|
||||||
|
|
||||||
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export type AdminControllerOptions = {
|
|||||||
debugRerenders?: boolean;
|
debugRerenders?: boolean;
|
||||||
theme?: "dark" | "light" | "system";
|
theme?: "dark" | "light" | "system";
|
||||||
logoReturnPath?: string;
|
logoReturnPath?: string;
|
||||||
|
manifest?: Manifest;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AdminController extends Controller {
|
export class AdminController extends Controller {
|
||||||
@@ -194,8 +196,10 @@ export class AdminController extends Controller {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isProd) {
|
if (isProd) {
|
||||||
let manifest: any;
|
let manifest: Manifest;
|
||||||
if (this.options.assetsPath.startsWith("http")) {
|
if (this.options.manifest) {
|
||||||
|
manifest = this.options.manifest;
|
||||||
|
} else if (this.options.assetsPath.startsWith("http")) {
|
||||||
manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", {
|
manifest = await fetch(this.options.assetsPath + ".vite/manifest.json", {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
@@ -204,14 +208,14 @@ export class AdminController extends Controller {
|
|||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
manifest = await import("bknd/dist/manifest.json", {
|
manifest = await import("bknd/dist/manifest.json", {
|
||||||
assert: { type: "json" },
|
with: { type: "json" },
|
||||||
}).then((res) => res.default);
|
}).then((res) => res.default);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @todo: load all marked as entry (incl. css)
|
// @todo: load all marked as entry (incl. css)
|
||||||
assets.js = manifest["src/ui/main.tsx"].file;
|
assets.js = manifest["src/ui/main.tsx"]?.file!;
|
||||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
assets.css = manifest["src/ui/main.tsx"]?.css?.[0] as any;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$console.warn("Couldn't find assets in manifest", e);
|
$console.warn("Couldn't find assets in manifest", e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const serverConfigSchema = $object(
|
|||||||
}),
|
}),
|
||||||
mcp: s.strictObject({
|
mcp: s.strictObject({
|
||||||
enabled: s.boolean({ default: false }),
|
enabled: s.boolean({ default: false }),
|
||||||
|
path: s.string({ default: "/api/system/mcp" }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
register(app: App) {
|
register(app: App) {
|
||||||
app.server.route("/api/system", this.getController());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export class SystemController extends Controller {
|
|||||||
const level = consoleMap[message.params.level];
|
const level = consoleMap[message.params.level];
|
||||||
if (!level) return;
|
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,
|
explainEndpoint: true,
|
||||||
},
|
},
|
||||||
endpoint: {
|
endpoint: {
|
||||||
path: "/mcp",
|
path: config.mcp.path as any,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
_init: isNode() ? { duplex: "half" } : {},
|
_init: isNode() ? { duplex: "half" } : {},
|
||||||
},
|
},
|
||||||
@@ -375,6 +376,7 @@ export class SystemController extends Controller {
|
|||||||
}),
|
}),
|
||||||
(c) =>
|
(c) =>
|
||||||
c.json({
|
c.json({
|
||||||
|
id: this.app._id,
|
||||||
version: {
|
version: {
|
||||||
config: c.get("app")?.version(),
|
config: c.get("app")?.version(),
|
||||||
bknd: getVersion(),
|
bknd: getVersion(),
|
||||||
|
|||||||
@@ -16,25 +16,49 @@ const schema = s.partialObject({
|
|||||||
metadata: s.string({ enum: ["copyright", "keep", "none"] }),
|
metadata: s.string({ enum: ["copyright", "keep", "none"] }),
|
||||||
quality: s.number({ minimum: 1, maximum: 100 }),
|
quality: s.number({ minimum: 1, maximum: 100 }),
|
||||||
});
|
});
|
||||||
type ImageOptimizationSchema = s.Static<typeof schema>;
|
export type CloudflareImageOptimizationSchema = s.Static<typeof schema>;
|
||||||
|
|
||||||
export type CloudflareImageOptimizationOptions = {
|
export type CloudflareImageOptimizationOptions = {
|
||||||
|
/**
|
||||||
|
* The url to access the image optimization plugin
|
||||||
|
* @default /api/plugin/image/optimize
|
||||||
|
*/
|
||||||
accessUrl?: string;
|
accessUrl?: string;
|
||||||
|
/**
|
||||||
|
* The path to resolve the image from
|
||||||
|
* @default /api/media/file
|
||||||
|
*/
|
||||||
resolvePath?: string;
|
resolvePath?: string;
|
||||||
|
/**
|
||||||
|
* Whether to explain the image optimization schema
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
explain?: boolean;
|
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;
|
cacheControl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function cloudflareImageOptimization({
|
export function cloudflareImageOptimization({
|
||||||
accessUrl = "/_plugin/image/optimize",
|
accessUrl = "/api/plugin/image/optimize",
|
||||||
resolvePath = "/api/media/file",
|
resolvePath = "/api/media/file",
|
||||||
explain = false,
|
explain = false,
|
||||||
defaultOptions = {},
|
defaultOptions = {},
|
||||||
fixedOptions = {},
|
fixedOptions = {},
|
||||||
}: CloudflareImageOptimizationOptions = {}): AppPlugin {
|
}: CloudflareImageOptimizationOptions = {}): AppPlugin {
|
||||||
const disallowedAccessUrls = ["/api", "/admin", "/_optimize"];
|
const disallowedAccessUrls = ["/api", "/admin", "/api/plugin"];
|
||||||
if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) {
|
if (disallowedAccessUrls.includes(accessUrl) || accessUrl.length < 2) {
|
||||||
throw new Error(`Disallowed accessUrl: ${accessUrl}`);
|
throw new Error(`Disallowed accessUrl: ${accessUrl}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
cloudflareImageOptimization,
|
cloudflareImageOptimization,
|
||||||
|
type CloudflareImageOptimizationSchema,
|
||||||
type CloudflareImageOptimizationOptions,
|
type CloudflareImageOptimizationOptions,
|
||||||
} from "./cloudflare/image-optimization.plugin";
|
} from "./cloudflare/image-optimization.plugin";
|
||||||
export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
|
export { showRoutes, type ShowRoutesOptions } from "./dev/show-routes.plugin";
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ const style = {
|
|||||||
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
basicChildStyle: "pl-5 ml-1 border-l border-muted hover:border-primary/20",
|
||||||
container: "ml-[-10px]",
|
container: "ml-[-10px]",
|
||||||
label: "text-primary/90 font-bold font-mono mr-2",
|
label: "text-primary/90 font-bold font-mono mr-2",
|
||||||
stringValue: "text-emerald-600 dark:text-emerald-500 font-mono select-text",
|
stringValue:
|
||||||
numberValue: "text-sky-500 dark:text-sky-400 font-mono",
|
"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",
|
nullValue: "text-zinc-400 font-mono",
|
||||||
undefinedValue: "text-zinc-400 font-mono",
|
undefinedValue: "text-zinc-400 font-mono",
|
||||||
otherValue: "text-zinc-400 font-mono",
|
otherValue: "text-zinc-400 font-mono",
|
||||||
@@ -42,7 +43,7 @@ export const JsonViewer = ({
|
|||||||
copyIconProps = {},
|
copyIconProps = {},
|
||||||
className,
|
className,
|
||||||
}: JsonViewerProps) => {
|
}: 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 formattedSize = formatNumber.fileSize(size ?? 0);
|
||||||
const showContext = size || title || showCopy;
|
const showContext = size || title || showCopy;
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
export const Icon = {
|
||||||
Warning,
|
Warning,
|
||||||
|
Err,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,24 +46,73 @@ export type DropzoneRenderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DropzoneProps = {
|
export type DropzoneProps = {
|
||||||
|
/**
|
||||||
|
* Get the upload info for a file
|
||||||
|
*/
|
||||||
getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string };
|
getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string };
|
||||||
|
/**
|
||||||
|
* Handle the deletion of a file
|
||||||
|
*/
|
||||||
handleDelete: (file: { path: string }) => Promise<boolean>;
|
handleDelete: (file: { path: string }) => Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* The initial items to display
|
||||||
|
*/
|
||||||
initialItems?: FileState[];
|
initialItems?: FileState[];
|
||||||
flow?: "start" | "end";
|
/**
|
||||||
|
* Maximum number of media items that can be uploaded
|
||||||
|
*/
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
|
/**
|
||||||
|
* The allowed mime types
|
||||||
|
*/
|
||||||
allowedMimeTypes?: string[];
|
allowedMimeTypes?: string[];
|
||||||
|
/**
|
||||||
|
* If true, the media item will be overwritten on entity media uploads if limit was reached
|
||||||
|
*/
|
||||||
overwrite?: boolean;
|
overwrite?: boolean;
|
||||||
|
/**
|
||||||
|
* If true, the media items will be uploaded automatically
|
||||||
|
*/
|
||||||
autoUpload?: boolean;
|
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;
|
onRejected?: (files: FileWithPath[]) => void;
|
||||||
|
/**
|
||||||
|
* The on deleted callback
|
||||||
|
*/
|
||||||
onDeleted?: (file: { path: string }) => void;
|
onDeleted?: (file: { path: string }) => void;
|
||||||
|
/**
|
||||||
|
* The on uploaded all callback
|
||||||
|
*/
|
||||||
onUploadedAll?: (files: FileStateWithData[]) => void;
|
onUploadedAll?: (files: FileStateWithData[]) => void;
|
||||||
|
/**
|
||||||
|
* The on uploaded callback
|
||||||
|
*/
|
||||||
onUploaded?: (file: FileStateWithData) => void;
|
onUploaded?: (file: FileStateWithData) => void;
|
||||||
|
/**
|
||||||
|
* The on clicked callback
|
||||||
|
*/
|
||||||
onClick?: (file: FileState) => void;
|
onClick?: (file: FileState) => void;
|
||||||
|
/**
|
||||||
|
* The placeholder to use
|
||||||
|
*/
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The footer to render
|
||||||
|
*/
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
|
/**
|
||||||
|
* The children to render
|
||||||
|
*/
|
||||||
children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode);
|
children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,15 +10,38 @@ import { mediaItemsToFileStates } from "./helper";
|
|||||||
import { useInViewport } from "@mantine/hooks";
|
import { useInViewport } from "@mantine/hooks";
|
||||||
|
|
||||||
export type DropzoneContainerProps = {
|
export type DropzoneContainerProps = {
|
||||||
|
/**
|
||||||
|
* The initial items to display
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
initialItems?: MediaFieldSchema[] | false;
|
initialItems?: MediaFieldSchema[] | false;
|
||||||
|
/**
|
||||||
|
* Whether to use infinite scrolling
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
infinite?: boolean;
|
infinite?: boolean;
|
||||||
|
/**
|
||||||
|
* If given, the initial media items fetched will be from this entity
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
entity?: {
|
entity?: {
|
||||||
name: string;
|
name: string;
|
||||||
id: PrimaryFieldType;
|
id: PrimaryFieldType;
|
||||||
field: string;
|
field: string;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* The media config
|
||||||
|
* @default undefined
|
||||||
|
*/
|
||||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||||
|
/**
|
||||||
|
* Query to filter the media items
|
||||||
|
*/
|
||||||
query?: RepoQueryIn;
|
query?: RepoQueryIn;
|
||||||
|
/**
|
||||||
|
* Whether to use a random filename
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
randomFilename?: boolean;
|
randomFilename?: boolean;
|
||||||
} & Omit<Partial<DropzoneProps>, "initialItems">;
|
} & Omit<Partial<DropzoneProps>, "initialItems">;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
import { useClickOutside, useHotkeys } from "@mantine/hooks";
|
||||||
import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
|
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 { throttle } from "lodash-es";
|
||||||
import { ScrollArea } from "radix-ui";
|
import { ScrollArea } from "radix-ui";
|
||||||
import {
|
import {
|
||||||
@@ -19,9 +19,21 @@ import { appShellStore } from "ui/store";
|
|||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
|
|
||||||
export function Root({ children }: { children: React.ReactNode }) {
|
export function Root({ children }: { children: React.ReactNode }) {
|
||||||
|
const sidebarWidths = appShellStore((store) => store.sidebars);
|
||||||
|
const style = transformObject(sidebarWidths, (value) => value.width);
|
||||||
return (
|
return (
|
||||||
<AppShellProvider>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</AppShellProvider>
|
</AppShellProvider>
|
||||||
@@ -218,6 +230,7 @@ const SidebarResize = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-shell="sidebar-resize"
|
||||||
data-active={isResizing ? 1 : undefined}
|
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"
|
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}
|
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", resize);
|
||||||
window.addEventListener("resize", throttle(updateHeaderHeight, 500));
|
|
||||||
}
|
return () => {
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} style={{ height: `${height - offset}px` }} {...props}>
|
<div ref={scrollRef} style={{ height: `${height - offset}px` }} {...props}>
|
||||||
|
|||||||
+5
-1
@@ -74,7 +74,7 @@
|
|||||||
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
@apply bg-background text-primary overflow-hidden h-dvh w-dvw;
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@apply bg-muted;
|
@apply bg-primary/15;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@@ -122,3 +122,7 @@ body,
|
|||||||
@utility debug-blue {
|
@utility debug-blue {
|
||||||
@apply border-blue-500 border;
|
@apply border-blue-500 border;
|
||||||
}
|
}
|
||||||
|
@utility w-min-content {
|
||||||
|
width: min-content;
|
||||||
|
width: -webkit-min-content;
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as ReactDOM from "react-dom/client";
|
import * as ReactDOM from "react-dom/client";
|
||||||
import Admin from "./Admin";
|
import Admin from "./Admin";
|
||||||
import "./main.css";
|
//import "./main.css";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function Adapters() {
|
|||||||
<span className="font-bold">Media Adapter:</span>
|
<span className="font-bold">Media Adapter:</span>
|
||||||
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
|
{ctx.selected === null && <span className="opacity-70"> (Choose one)</span>}
|
||||||
</Formy.Label>
|
</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) => (
|
{ctx.schemas?.map((schema: any, i) => (
|
||||||
<Button
|
<Button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -165,7 +165,7 @@ function Adapters() {
|
|||||||
</div>
|
</div>
|
||||||
{ctx.selected !== null && (
|
{ctx.selected !== null && (
|
||||||
<Formy.Group as="fieldset" error={ctx.errors.length > 0}>
|
<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!)}
|
{autoFormatString(ctx.selectedSchema!.title!)}
|
||||||
</Formy.Label>
|
</Formy.Label>
|
||||||
<FormContextOverride schema={ctx.selectedSchema} prefix={ctx.path}>
|
<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 });
|
||||||
|
}
|
||||||
@@ -7,13 +7,17 @@ import { useBknd } from "ui/client/bknd";
|
|||||||
import { Empty } from "ui/components/display/Empty";
|
import { Empty } from "ui/components/display/Empty";
|
||||||
import { Button } from "ui/components/buttons/Button";
|
import { Button } from "ui/components/buttons/Button";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
|
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||||
|
|
||||||
export default function ToolsMcp() {
|
export default function ToolsMcp() {
|
||||||
|
useBrowserTitle(["MCP UI"]);
|
||||||
|
|
||||||
const { config, options } = useBknd();
|
const { config, options } = useBknd();
|
||||||
const feature = useMcpStore((state) => state.feature);
|
const feature = useMcpStore((state) => state.feature);
|
||||||
const setFeature = useMcpStore((state) => state.setFeature);
|
const setFeature = useMcpStore((state) => state.setFeature);
|
||||||
const content = useMcpStore((state) => state.content);
|
const content = useMcpStore((state) => state.content);
|
||||||
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
const openSidebar = appShellStore((store) => store.toggleSidebar("default"));
|
||||||
|
const mcpPath = config.server.mcp.path;
|
||||||
|
|
||||||
if (!config.server.mcp.enabled) {
|
if (!config.server.mcp.enabled) {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +40,7 @@ export default function ToolsMcp() {
|
|||||||
<TbWorld />
|
<TbWorld />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="block truncate text-sm font-mono leading-none">
|
<span className="block truncate text-sm font-mono leading-none">
|
||||||
{window.location.origin + "/mcp"}
|
{window.location.origin + mcpPath}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const useMcpStore = create(
|
|||||||
content: null as ToolJson | null,
|
content: null as ToolJson | null,
|
||||||
history: [] as { type: "request" | "response"; data: any }[],
|
history: [] as { type: "request" | "response"; data: any }[],
|
||||||
historyLimit: 50,
|
historyLimit: 50,
|
||||||
historyVisible: true,
|
historyVisible: false,
|
||||||
},
|
},
|
||||||
(set) => ({
|
(set) => ({
|
||||||
setTools: (tools: ToolJson[]) => set({ tools }),
|
setTools: (tools: ToolJson[]) => set({ tools }),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
|
||||||
import { getClient, getTemplate } from "./utils";
|
import { getTemplate } from "./utils";
|
||||||
import { useMcpStore } from "./state";
|
import { useMcpStore } from "./state";
|
||||||
import { AppShell } from "ui/layouts/AppShell";
|
import { AppShell } from "ui/layouts/AppShell";
|
||||||
import { TbHistory, TbHistoryOff, TbRefresh } from "react-icons/tb";
|
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 { Button } from "ui/components/buttons/Button";
|
||||||
import * as Formy from "ui/components/form/Formy";
|
import * as Formy from "ui/components/form/Formy";
|
||||||
import { appShellStore } from "ui/store";
|
import { appShellStore } from "ui/store";
|
||||||
|
import { Icon } from "ui/components/display/Icon";
|
||||||
|
import { useMcpClient } from "./hooks/use-mcp-client";
|
||||||
|
|
||||||
export function Sidebar({ open, toggle }) {
|
export function Sidebar({ open, toggle }) {
|
||||||
const client = getClient();
|
const client = useMcpClient();
|
||||||
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
const closeSidebar = appShellStore((store) => store.closeSidebar("default"));
|
||||||
const tools = useMcpStore((state) => state.tools);
|
const tools = useMcpStore((state) => state.tools);
|
||||||
const setTools = useMcpStore((state) => state.setTools);
|
const setTools = useMcpStore((state) => state.setTools);
|
||||||
@@ -20,11 +22,18 @@ export function Sidebar({ open, toggle }) {
|
|||||||
const content = useMcpStore((state) => state.content);
|
const content = useMcpStore((state) => state.content);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [query, setQuery] = useState<string>("");
|
const [query, setQuery] = useState<string>("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
const handleRefresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
const res = await client.listTools();
|
const res = await client.listTools();
|
||||||
if (res) setTools(res.tools);
|
if (res) setTools(res.tools);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -39,6 +48,7 @@ export function Sidebar({ open, toggle }) {
|
|||||||
toggle={toggle}
|
toggle={toggle}
|
||||||
renderHeaderRight={() => (
|
renderHeaderRight={() => (
|
||||||
<div className="flex flex-row gap-2 items-center">
|
<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">
|
<span className="flex-inline bg-primary/10 px-2 py-1.5 rounded-xl text-sm font-mono leading-none">
|
||||||
{tools.length}
|
{tools.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -88,7 +98,7 @@ export function Content() {
|
|||||||
const [result, setResult] = useState<object | null>(null);
|
const [result, setResult] = useState<object | null>(null);
|
||||||
const historyVisible = useMcpStore((state) => state.historyVisible);
|
const historyVisible = useMcpStore((state) => state.historyVisible);
|
||||||
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
const setHistoryVisible = useMcpStore((state) => state.setHistoryVisible);
|
||||||
const client = getClient();
|
const client = useMcpClient();
|
||||||
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
const jsonViewerTabsRef = useRef<JsonViewerTabsRef>(null);
|
||||||
const hasInputSchema =
|
const hasInputSchema =
|
||||||
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
content?.inputSchema && Object.keys(content.inputSchema.properties ?? {}).length > 0;
|
||||||
@@ -128,8 +138,8 @@ export function Content() {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-col max-w-screen">
|
|
||||||
<Form
|
<Form
|
||||||
|
className="flex flex-grow flex-col min-w-0 max-w-screen"
|
||||||
key={content.name}
|
key={content.name}
|
||||||
schema={{
|
schema={{
|
||||||
title: "InputSchema",
|
title: "InputSchema",
|
||||||
@@ -170,7 +180,12 @@ export function Content() {
|
|||||||
</AppShell.SectionHeaderTitle>
|
</AppShell.SectionHeaderTitle>
|
||||||
</AppShell.SectionHeader>
|
</AppShell.SectionHeader>
|
||||||
<div className="flex flex-grow flex-row w-vw">
|
<div className="flex flex-grow flex-row w-vw">
|
||||||
<div className="flex flex-grow flex-col w-full">
|
<div
|
||||||
|
className="flex flex-grow flex-col max-w-full"
|
||||||
|
style={{
|
||||||
|
width: "calc(100% - var(--sidebar-width-right) - 1px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
<div key={JSON.stringify(content)} className="flex flex-col py-4 px-5 gap-4">
|
||||||
<p className="text-primary/80">{content?.description}</p>
|
<p className="text-primary/80">{content?.description}</p>
|
||||||
@@ -204,7 +219,6 @@ export function Content() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,8 +234,9 @@ const History = () => {
|
|||||||
<JsonViewer
|
<JsonViewer
|
||||||
key={`${item.type}-${i}`}
|
key={`${item.type}-${i}`}
|
||||||
json={item.data}
|
json={item.data}
|
||||||
title={item.type}
|
title={item.type.substring(0, 3)}
|
||||||
expand={2}
|
expand={2}
|
||||||
|
showCopy
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
import { McpClient, type McpClientConfig } from "jsonv-ts/mcp";
|
|
||||||
import { Draft2019 } from "json-schema-library";
|
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) {
|
export function getTemplate(schema: object) {
|
||||||
if (!schema || schema === undefined || schema === null) return undefined;
|
if (!schema || schema === undefined || schema === null) return undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ html.fixed,
|
|||||||
html.fixed body {
|
html.fixed body {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 100%;
|
right: 0;
|
||||||
width: 100%;
|
bottom: 0;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 100dvw;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior-x: contain;
|
overscroll-behavior-x: contain;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { nodeSqlite } from "./src/adapter/node/connection/NodeSqliteConnection";
|
|||||||
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
import { libsql } from "./src/data/connection/sqlite/libsql/LibsqlConnection";
|
||||||
import { $console } from "core/utils/console";
|
import { $console } from "core/utils/console";
|
||||||
import { createClient } from "@libsql/client";
|
import { createClient } from "@libsql/client";
|
||||||
|
import util from "node:util";
|
||||||
|
|
||||||
|
util.inspect.defaultOptions.depth = 5;
|
||||||
|
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "bknd",
|
"name": "bknd",
|
||||||
"version": "0.16.1",
|
"version": "0.17.1",
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"hono": "4.8.3",
|
"hono": "4.8.3",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.8.1",
|
"jsonv-ts": "0.8.2",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.27.6",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -151,7 +151,6 @@
|
|||||||
"bknd": "workspace:*",
|
"bknd": "workspace:*",
|
||||||
"kysely-neon": "^1.3.0",
|
"kysely-neon": "^1.3.0",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"typescript": "^5.8.2",
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"kysely": "^0.27.6",
|
"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/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=="],
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
@@ -2516,7 +2515,7 @@
|
|||||||
|
|
||||||
"jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="],
|
"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=="],
|
"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/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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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
@@ -5,7 +5,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
# define bknd version to be used as:
|
# define bknd version to be used as:
|
||||||
# `docker build --build-arg VERSION=<version> -t bknd .`
|
# `docker build --build-arg VERSION=<version> -t bknd .`
|
||||||
ARG VERSION=0.13.0
|
ARG VERSION=0.17.1
|
||||||
|
|
||||||
# Install & copy required cli
|
# Install & copy required cli
|
||||||
RUN npm install --omit=dev bknd@${VERSION}
|
RUN npm install --omit=dev bknd@${VERSION}
|
||||||
@@ -16,10 +16,10 @@ FROM node:24-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install pm2 and libsql
|
# Install required dependencies
|
||||||
RUN npm install -g pm2
|
RUN npm install -g pm2
|
||||||
RUN echo '{"type":"module"}' > package.json
|
RUN echo '{"type":"module"}' > package.json
|
||||||
RUN npm install @libsql/client
|
RUN npm install jsonv-ts @libsql/client
|
||||||
|
|
||||||
# Create volume and init args
|
# Create volume and init args
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export const examples = {
|
|||||||
adminRich: {
|
adminRich: {
|
||||||
path: "github/bknd-io/bknd-examples",
|
path: "github/bknd-io/bknd-examples",
|
||||||
startScript: "example-admin-rich",
|
startScript: "example-admin-rich",
|
||||||
initialPath: "/data/schema"
|
initialPath: "/data/schema",
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StackBlitz = ({
|
export const StackBlitz = ({
|
||||||
@@ -30,12 +30,10 @@ export const StackBlitz = ({
|
|||||||
embed: "1",
|
embed: "1",
|
||||||
view: "preview",
|
view: "preview",
|
||||||
...(selected || {}),
|
...(selected || {}),
|
||||||
...props
|
...props,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = new URL(
|
const url = new URL(`https://stackblitz.com/${finalPath}?${params.toString()}`);
|
||||||
`https://stackblitz.com/${finalPath}?${params.toString()}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,7 +42,7 @@ export const StackBlitz = ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
paddingTop: `${ratio * 100}%`
|
paddingTop: `${ratio * 100}%`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<iframe
|
<iframe
|
||||||
@@ -57,7 +55,7 @@ export const StackBlitz = ({
|
|||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
border: "none"
|
border: "none",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +65,7 @@ export const StackBlitz = ({
|
|||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
marginTop: "0.2rem",
|
marginTop: "0.2rem",
|
||||||
marginBottom: "1rem",
|
marginBottom: "1rem",
|
||||||
textAlign: "center"
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
If you’re having issues viewing it inline,{" "}
|
If you’re having issues viewing it inline,{" "}
|
||||||
|
|||||||
@@ -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 }) {
|
export function JsonSchemaTypeTable({ schema }: { schema: JSONSchemaDefinition }) {
|
||||||
const properties = schema.properties ?? {};
|
const properties = schema.properties ?? {};
|
||||||
const required = schema.required ?? [];
|
const required = schema.required ?? [];
|
||||||
@@ -44,8 +67,8 @@ export function JsonSchemaTypeTable({ schema }: { schema: JSONSchemaDefinition }
|
|||||||
typeDescription: (
|
typeDescription: (
|
||||||
<DynamicCodeBlock lang="json" code={indent(getTypeDescription(value), 1)} />
|
<DynamicCodeBlock lang="json" code={indent(getTypeDescription(value), 1)} />
|
||||||
),
|
),
|
||||||
type: value.type,
|
type: getType(value),
|
||||||
default: value.default ? JSON.stringify(value.default) : undefined,
|
default: value.default !== undefined ? JSON.stringify(value.default) : undefined,
|
||||||
required: required.includes(key),
|
required: required.includes(key),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -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) => {
|
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
||||||
// ^? AppRequest
|
// ^? AppRequest
|
||||||
console.log("Request received", event.request.url);
|
console.log("Request received", event.params.request.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.emgr.on("app-request", async (event) => {
|
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) => {
|
app.emgr.onAny(async (event, name) => {
|
||||||
console.log("Event received", event.slug);
|
console.log("Event received", event, name);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ import { AppEvents } from "bknd";
|
|||||||
export default {
|
export default {
|
||||||
onBuilt: (app) => {
|
onBuilt: (app) => {
|
||||||
app.emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
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(
|
app.emgr.onEvent(
|
||||||
AppEvents.AppRequest,
|
AppEvents.AppRequest,
|
||||||
async (event) => {
|
async (event) => {
|
||||||
console.log("Request received", event.request.url);
|
console.log("Request received", event.params.request.url);
|
||||||
},
|
},
|
||||||
{ mode: "sync" },
|
{ 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";
|
import { serve } from "bknd/adapter/nextjs";
|
||||||
|
|
||||||
// optionally, you can set the runtime to edge for better performance
|
// optionally, you can set the runtime to edge for better performance
|
||||||
export const runtime = "edge";
|
// export const runtime = "edge";
|
||||||
|
|
||||||
const handler = serve({
|
const handler = serve({
|
||||||
...config,
|
...config,
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ bun add bknd
|
|||||||
|
|
||||||
## Serve the API
|
## 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
|
If you don't choose anything specific, it uses the first D1 binding it finds.
|
||||||
chapter [Using a different mode](#using-a-different-mode) for available modes.
|
|
||||||
|
|
||||||
```ts title="src/index.ts"
|
```ts title="src/index.ts"
|
||||||
import { serve, d1 } from "bknd/adapter/cloudflare";
|
import { serve, d1 } from "bknd/adapter/cloudflare";
|
||||||
@@ -123,125 +122,13 @@ import { serve } from "bknd/adapter/cloudflare";
|
|||||||
export default serve<Env>({
|
export default serve<Env>({
|
||||||
// ...
|
// ...
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
// [!code highlight]
|
|
||||||
app.server.get("/hello", (c) => c.json({ hello: "world" })); // [!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.
|
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 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:
|
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";
|
import { serve } from "bknd/adapter/cloudflare";
|
||||||
|
|
||||||
export default serve({
|
export default serve({
|
||||||
// currently recommended to use "fresh" mode
|
|
||||||
// otherwise consecutive requests will use the same bookmark
|
|
||||||
mode: "fresh",
|
|
||||||
// ...
|
// ...
|
||||||
d1: {
|
d1: {
|
||||||
// enables D1 sessions
|
// enables D1 sessions
|
||||||
@@ -272,3 +156,83 @@ If bknd is used in a stateful user context (like in a browser), it'll automatica
|
|||||||
```bash
|
```bash
|
||||||
curl -H "x-cf-d1-session: <bookmark>" ...
|
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/sdk",
|
||||||
"./usage/react",
|
"./usage/react",
|
||||||
"./usage/elements",
|
"./usage/elements",
|
||||||
|
"./usage/mcp/",
|
||||||
"---Extending---",
|
"---Extending---",
|
||||||
"./extending/config",
|
"./extending/config",
|
||||||
"./extending/events",
|
"./extending/events",
|
||||||
|
"./extending/plugins",
|
||||||
"---Integration---",
|
"---Integration---",
|
||||||
"./integration/introduction",
|
"./integration/introduction",
|
||||||
"./integration/(frameworks)/",
|
"./integration/(frameworks)/",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": ["overview", "mcp"]
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,7 @@ Glad you're here! **bknd** is a lightweight, infrastructure agnostic and feature
|
|||||||
|
|
||||||
Here is a preview of **bknd** in StackBlitz:
|
Here is a preview of **bknd** in StackBlitz:
|
||||||
|
|
||||||
<StackBlitz {...examples.adminRich} />
|
<StackBlitz path="github/bknd-io/bknd-demo" initialPath="/" />
|
||||||
|
|
||||||
<Accordions>
|
<Accordions>
|
||||||
<Accordion title="What's going on?">
|
<Accordion title="What's going on?">
|
||||||
|
|||||||
@@ -16,21 +16,23 @@ Here is the output:
|
|||||||
$ npx bknd
|
$ npx bknd
|
||||||
Usage: bknd [options] [command]
|
Usage: bknd [options] [command]
|
||||||
|
|
||||||
⚡ bknd cli v0.16.0
|
⚡ bknd cli v0.17.0
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-V, --version output the version number
|
-V, --version output the version number
|
||||||
-h, --help display help for command
|
-h, --help display help for command
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
user <action> create/update users, or generate a token (auth)
|
config [options] get app config
|
||||||
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
|
copy-assets [options] copy static assets
|
||||||
config [options] get default config
|
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
|
help [command] display help for command
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ Example using `@neondatabase/serverless`:
|
|||||||
import { createCustomPostgresConnection } from "@bknd/postgres";
|
import { createCustomPostgresConnection } from "@bknd/postgres";
|
||||||
import { NeonDialect } from "kysely-neon";
|
import { NeonDialect } from "kysely-neon";
|
||||||
|
|
||||||
const neon = createCustomPostgresConnection(NeonDialect);
|
const neon = createCustomPostgresConnection("neon", NeonDialect);
|
||||||
|
|
||||||
serve({
|
serve({
|
||||||
connection: neon({
|
connection: neon({
|
||||||
@@ -247,7 +247,7 @@ const xata = new client({
|
|||||||
branch: process.env.XATA_BRANCH,
|
branch: process.env.XATA_BRANCH,
|
||||||
});
|
});
|
||||||
|
|
||||||
const xataConnection = createCustomPostgresConnection(XataDialect, {
|
const xataConnection = createCustomPostgresConnection("xata", XataDialect, {
|
||||||
supports: {
|
supports: {
|
||||||
batching: false,
|
batching: false,
|
||||||
},
|
},
|
||||||
@@ -349,6 +349,91 @@ Note that we didn't add relational fields directly to the entity, but instead de
|
|||||||
manually.
|
manually.
|
||||||
</Callout>
|
</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
|
### Type completion
|
||||||
|
|
||||||
To get type completion, there are two options:
|
To get type completion, there are two options:
|
||||||
|
|||||||
@@ -44,16 +44,8 @@ export default function UserAvatar() {
|
|||||||
|
|
||||||
#### Props
|
#### Props
|
||||||
|
|
||||||
- `initialItems?: xMediaFieldSchema[]`: Initial items to display, must be an array of media objects.
|
<AutoTypeTable path="../app/src/ui/elements/media/DropzoneContainer.tsx" name="DropzoneContainerProps" />
|
||||||
- `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.
|
|
||||||
|
|
||||||
#### Customize Rendering
|
#### 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
|
||||||
|
```
|
||||||
+193
-199
File diff suppressed because one or more lines are too long
@@ -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 } });
|
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`)
|
## Auth (`api.auth`)
|
||||||
|
|
||||||
Access the `Auth` specific API methods at `api.auth`. If there is successful authentication, the
|
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
|
```ts
|
||||||
const { data } = await api.auth.me();
|
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");
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
+1058
-981
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import type { MDXComponents } from "mdx/types";
|
|||||||
import * as FilesComponents from "fumadocs-ui/components/files";
|
import * as FilesComponents from "fumadocs-ui/components/files";
|
||||||
import * as TabsComponents from "fumadocs-ui/components/tabs";
|
import * as TabsComponents from "fumadocs-ui/components/tabs";
|
||||||
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
|
import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
|
||||||
|
import { ImageZoom } from "fumadocs-ui/components/image-zoom";
|
||||||
import {
|
import {
|
||||||
CalloutInfo,
|
CalloutInfo,
|
||||||
CalloutPositive,
|
CalloutPositive,
|
||||||
@@ -18,6 +19,7 @@ import * as Twoslash from "fumadocs-twoslash/ui";
|
|||||||
|
|
||||||
import { createGenerator } from "fumadocs-typescript";
|
import { createGenerator } from "fumadocs-typescript";
|
||||||
import { AutoTypeTable } from "fumadocs-typescript/ui";
|
import { AutoTypeTable } from "fumadocs-typescript/ui";
|
||||||
|
import { Wrapper } from "@/components/misc/Wrapper";
|
||||||
|
|
||||||
const generator = createGenerator({
|
const generator = createGenerator({
|
||||||
tsconfigPath: "../tsconfig.json",
|
tsconfigPath: "../tsconfig.json",
|
||||||
@@ -37,10 +39,10 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
|||||||
CalloutDanger,
|
CalloutDanger,
|
||||||
StackBlitz,
|
StackBlitz,
|
||||||
Icon,
|
Icon,
|
||||||
|
Wrapper,
|
||||||
|
img: (props) => <ImageZoom {...(props as any)} />,
|
||||||
APIPage: (props) => <APIPage {...openapi.getAPIPageProps(props)} />,
|
APIPage: (props) => <APIPage {...openapi.getAPIPageProps(props)} />,
|
||||||
AutoTypeTable: (props) => (
|
AutoTypeTable: (props) => <AutoTypeTable {...props} generator={generator} />,
|
||||||
<AutoTypeTable {...props} generator={generator} />
|
|
||||||
),
|
|
||||||
...components,
|
...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 |
@@ -6,18 +6,12 @@ import { writeFile, readFile } from "node:fs/promises";
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
mcpConfig: "./mcp.json",
|
mcpConfig: "./mcp.json",
|
||||||
outFile: "./content/docs/(documentation)/modules/server/mcp.mdx",
|
outFile: "./content/docs/(documentation)/usage/mcp/tools-resources.mdx",
|
||||||
};
|
};
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
console.info("Generating MCP documentation...");
|
console.info("Generating MCP documentation...");
|
||||||
|
|
||||||
try {
|
|
||||||
console.log("bun version", Bun.version);
|
|
||||||
} catch (e) {
|
|
||||||
console.log("bun failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
const mcpConfig = JSON.parse(await readFile(config.mcpConfig, "utf-8"));
|
const mcpConfig = JSON.parse(await readFile(config.mcpConfig, "utf-8"));
|
||||||
const document = await generateDocument(mcpConfig);
|
const document = await generateDocument(mcpConfig);
|
||||||
@@ -33,8 +27,8 @@ async function generateDocument({
|
|||||||
resources: ReturnType<Resource["toJSON"]>[];
|
resources: ReturnType<Resource["toJSON"]>[];
|
||||||
}) {
|
}) {
|
||||||
return `---
|
return `---
|
||||||
title: "MCP"
|
title: "Tools & Resources"
|
||||||
description: "Built-in full featured MCP server."
|
description: "Tools & Resources of the built-in full featured MCP server."
|
||||||
tags: ["documentation"]
|
tags: ["documentation"]
|
||||||
---
|
---
|
||||||
import { JsonSchemaTypeTable } from '@/components/McpTool';
|
import { JsonSchemaTypeTable } from '@/components/McpTool';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* We're using separate files, so that "wrangler" doesn't get bundled with your worker.
|
* 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";
|
import config from "./config.ts";
|
||||||
|
|
||||||
export default withPlatformProxy(config);
|
export default withPlatformProxy(config);
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare";
|
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 {
|
export default {
|
||||||
d1: {
|
d1: {
|
||||||
session: true,
|
session: true,
|
||||||
},
|
},
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
syncTypes({
|
|
||||||
enabled: isDev,
|
|
||||||
write: async (et) => {
|
|
||||||
await writeFile("bknd-types.d.ts", et.toString());
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} satisfies CloudflareBkndConfig;
|
} satisfies CloudflareBkndConfig;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "node_modules/wrangler/config-schema.json",
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
"name": "bknd-cf-worker-example",
|
"name": "bknd-cf-worker-example",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"compatibility_date": "2025-02-04",
|
"compatibility_date": "2025-08-03",
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
"workers_dev": true,
|
"workers_dev": true,
|
||||||
"minify": true,
|
"minify": true,
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ You can create a custom kysely postgres dialect by using the `createCustomPostgr
|
|||||||
```ts
|
```ts
|
||||||
import { createCustomPostgresConnection } from "@bknd/postgres";
|
import { createCustomPostgresConnection } from "@bknd/postgres";
|
||||||
|
|
||||||
const connection = createCustomPostgresConnection(MyDialect)({
|
const connection = createCustomPostgresConnection("my_postgres_dialect", MyDialect)({
|
||||||
// your custom dialect configuration
|
// your custom dialect configuration
|
||||||
supports: {
|
supports: {
|
||||||
batching: true
|
batching: true
|
||||||
@@ -75,7 +75,7 @@ const connection = createCustomPostgresConnection(MyDialect)({
|
|||||||
import { createCustomPostgresConnection } from "@bknd/postgres";
|
import { createCustomPostgresConnection } from "@bknd/postgres";
|
||||||
import { NeonDialect } from "kysely-neon";
|
import { NeonDialect } from "kysely-neon";
|
||||||
|
|
||||||
const connection = createCustomPostgresConnection(NeonDialect)({
|
const connection = createCustomPostgresConnection("neon", NeonDialect)({
|
||||||
connectionString: process.env.NEON,
|
connectionString: process.env.NEON,
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -94,7 +94,7 @@ const xata = new client({
|
|||||||
branch: process.env.XATA_BRANCH,
|
branch: process.env.XATA_BRANCH,
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = createCustomPostgresConnection(XataDialect, {
|
const connection = createCustomPostgresConnection("xata", XataDialect, {
|
||||||
supports: {
|
supports: {
|
||||||
batching: false,
|
batching: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@bknd/postgres",
|
"name": "@bknd/postgres",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
@@ -31,8 +31,7 @@
|
|||||||
"@xata.io/kysely": "^0.2.1",
|
"@xata.io/kysely": "^0.2.1",
|
||||||
"bknd": "workspace:*",
|
"bknd": "workspace:*",
|
||||||
"kysely-neon": "^1.3.0",
|
"kysely-neon": "^1.3.0",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0"
|
||||||
"typescript": "^5.8.2"
|
|
||||||
},
|
},
|
||||||
"tsup": {
|
"tsup": {
|
||||||
"entry": ["src/index.ts"],
|
"entry": ["src/index.ts"],
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Kysely, PostgresDialect } from "kysely";
|
import { Kysely, PostgresDialect } from "kysely";
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
import { PostgresConnection, plugins } from "./PostgresConnection";
|
||||||
import { customIntrospector } from "bknd/data";
|
import { customIntrospector } from "bknd";
|
||||||
import $pg from "pg";
|
import $pg from "pg";
|
||||||
|
|
||||||
export type PgPostgresConnectionConfig = $pg.PoolConfig;
|
export type PgPostgresConnectionConfig = $pg.PoolConfig;
|
||||||
|
|
||||||
export class PgPostgresConnection extends PostgresConnection {
|
export class PgPostgresConnection extends PostgresConnection {
|
||||||
|
override name = "pg";
|
||||||
private pool: $pg.Pool;
|
private pool: $pg.Pool;
|
||||||
|
|
||||||
constructor(config: PgPostgresConnectionConfig) {
|
constructor(config: PgPostgresConnectionConfig) {
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
ParseJSONResultsPlugin,
|
ParseJSONResultsPlugin,
|
||||||
type ColumnDataType,
|
type ColumnDataType,
|
||||||
@@ -13,12 +20,13 @@ export type QB = SelectQueryBuilder<any, any, any>;
|
|||||||
|
|
||||||
export const plugins = [new ParseJSONResultsPlugin()];
|
export const plugins = [new ParseJSONResultsPlugin()];
|
||||||
|
|
||||||
export abstract class PostgresConnection<DB = any> extends Connection<DB> {
|
export abstract class PostgresConnection extends Connection {
|
||||||
protected override readonly supported = {
|
protected override readonly supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
|
softscans: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(kysely: Kysely<DB>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
|
constructor(kysely: Kysely<any>, fn?: Partial<DbFunctions>, _plugins?: KyselyPlugin[]) {
|
||||||
super(
|
super(
|
||||||
kysely,
|
kysely,
|
||||||
fn ?? {
|
fn ?? {
|
||||||
@@ -73,13 +81,9 @@ export abstract class PostgresConnection<DB = any> extends Connection<DB> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async batch<Queries extends QB[]>(
|
override async executeQueries<O extends ConnQuery[]>(...qbs: O): Promise<ConnQueryResults<O>> {
|
||||||
queries: [...Queries],
|
|
||||||
): Promise<{
|
|
||||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
|
||||||
}> {
|
|
||||||
return this.kysely.transaction().execute(async (trx) => {
|
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;
|
}) as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type SchemaMetadata, sql } from "kysely";
|
import { type SchemaMetadata, sql } from "kysely";
|
||||||
import { BaseIntrospector } from "bknd/data";
|
import { BaseIntrospector } from "bknd";
|
||||||
|
|
||||||
type PostgresSchemaSpec = {
|
type PostgresSchemaSpec = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Kysely } from "kysely";
|
import { Kysely } from "kysely";
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
import { PostgresConnection, plugins } from "./PostgresConnection";
|
||||||
import { customIntrospector } from "bknd/data";
|
import { customIntrospector } from "bknd";
|
||||||
import { PostgresJSDialect } from "kysely-postgres-js";
|
import { PostgresJSDialect } from "kysely-postgres-js";
|
||||||
import $postgresJs, { type Sql, type Options, type PostgresType } from "postgres";
|
import $postgresJs, { type Sql, type Options, type PostgresType } from "postgres";
|
||||||
|
|
||||||
export type PostgresJsConfig = Options<Record<string, PostgresType>>;
|
export type PostgresJsConfig = Options<Record<string, PostgresType>>;
|
||||||
|
|
||||||
export class PostgresJsConnection extends PostgresConnection {
|
export class PostgresJsConnection extends PostgresConnection {
|
||||||
|
override name = "postgres-js";
|
||||||
|
|
||||||
private postgres: Sql;
|
private postgres: Sql;
|
||||||
|
|
||||||
constructor(opts: { postgres: Sql }) {
|
constructor(opts: { postgres: Sql }) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Constructor } from "bknd/core";
|
import { customIntrospector, type DbFunctions } from "bknd";
|
||||||
import { customIntrospector, type DbFunctions } from "bknd/data";
|
|
||||||
import { Kysely, type Dialect, type KyselyPlugin } from "kysely";
|
import { Kysely, type Dialect, type KyselyPlugin } from "kysely";
|
||||||
import { plugins, PostgresConnection } from "./PostgresConnection";
|
import { plugins, PostgresConnection } from "./PostgresConnection";
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||||
|
|
||||||
|
export type Constructor<T> = new (...args: any[]) => T;
|
||||||
|
|
||||||
export type CustomPostgresConnection = {
|
export type CustomPostgresConnection = {
|
||||||
supports?: PostgresConnection["supported"];
|
supports?: PostgresConnection["supported"];
|
||||||
fn?: Partial<DbFunctions>;
|
fn?: Partial<DbFunctions>;
|
||||||
@@ -15,17 +16,19 @@ export function createCustomPostgresConnection<
|
|||||||
T extends Constructor<Dialect>,
|
T extends Constructor<Dialect>,
|
||||||
C extends ConstructorParameters<T>[0],
|
C extends ConstructorParameters<T>[0],
|
||||||
>(
|
>(
|
||||||
|
name: string,
|
||||||
dialect: Constructor<Dialect>,
|
dialect: Constructor<Dialect>,
|
||||||
options?: CustomPostgresConnection,
|
options?: CustomPostgresConnection,
|
||||||
): (config: C) => PostgresConnection<any> {
|
): (config: C) => PostgresConnection {
|
||||||
const supported = {
|
const supported = {
|
||||||
batching: true,
|
batching: true,
|
||||||
...((options?.supports ?? {}) as any),
|
...((options?.supports ?? {}) as any),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (config: C) =>
|
return (config: C) =>
|
||||||
new (class extends PostgresConnection<any> {
|
new (class extends PostgresConnection {
|
||||||
protected override readonly supported = supported;
|
override name = name;
|
||||||
|
override readonly supported = supported;
|
||||||
|
|
||||||
constructor(config: C) {
|
constructor(config: C) {
|
||||||
super(
|
super(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, beforeAll, afterAll, expect, it, afterEach } from "bun:test";
|
import { describe, beforeAll, afterAll, expect, it, afterEach } from "bun:test";
|
||||||
import type { PostgresConnection } from "../src";
|
import type { PostgresConnection } from "../src";
|
||||||
import { createApp } from "bknd";
|
import { createApp, em, entity, text } from "bknd";
|
||||||
import * as proto from "bknd/data";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "bknd/utils";
|
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 = {
|
export type TestSuiteConfig = {
|
||||||
createConnection: () => InstanceType<typeof PostgresConnection>;
|
createConnection: () => InstanceType<typeof PostgresConnection>;
|
||||||
@@ -12,8 +15,9 @@ export type TestSuiteConfig = {
|
|||||||
export async function defaultCleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
|
export async function defaultCleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
|
||||||
const kysely = connection.kysely;
|
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.dropSchema("public").ifExists().cascade().execute();
|
||||||
|
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
|
||||||
await kysely.schema.createSchema("public").execute();
|
await kysely.schema.createSchema("public").execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +36,23 @@ export function testSuite(config: TestSuiteConfig) {
|
|||||||
beforeAll(() => disableConsoleLog(["log", "warn", "error"]));
|
beforeAll(() => disableConsoleLog(["log", "warn", "error"]));
|
||||||
afterAll(() => enableConsoleLog());
|
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", () => {
|
describe("base", () => {
|
||||||
it("should connect to the database", async () => {
|
it("should connect to the database", async () => {
|
||||||
const connection = config.createConnection();
|
const connection = config.createConnection();
|
||||||
@@ -73,14 +94,14 @@ export function testSuite(config: TestSuiteConfig) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should create a basic schema", async () => {
|
it("should create a basic schema", async () => {
|
||||||
const schema = proto.em(
|
const schema = em(
|
||||||
{
|
{
|
||||||
posts: proto.entity("posts", {
|
posts: entity("posts", {
|
||||||
title: proto.text().required(),
|
title: text().required(),
|
||||||
content: proto.text(),
|
content: text(),
|
||||||
}),
|
}),
|
||||||
comments: proto.entity("comments", {
|
comments: entity("comments", {
|
||||||
content: proto.text(),
|
content: text(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
(fns, s) => {
|
(fns, s) => {
|
||||||
@@ -153,20 +174,20 @@ export function testSuite(config: TestSuiteConfig) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should support uuid", async () => {
|
it("should support uuid", async () => {
|
||||||
const schema = proto.em(
|
const schema = em(
|
||||||
{
|
{
|
||||||
posts: proto.entity(
|
posts: entity(
|
||||||
"posts",
|
"posts",
|
||||||
{
|
{
|
||||||
title: proto.text().required(),
|
title: text().required(),
|
||||||
content: proto.text(),
|
content: text(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
primary_format: "uuid",
|
primary_format: "uuid",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
comments: proto.entity("comments", {
|
comments: entity("comments", {
|
||||||
content: proto.text(),
|
content: text(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
(fns, s) => {
|
(fns, s) => {
|
||||||
@@ -187,8 +208,8 @@ export function testSuite(config: TestSuiteConfig) {
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
expect(config.data.entities?.posts.fields?.id.config?.format).toBe("uuid");
|
expect(config.data.entities?.posts.fields?.id.config?.format).toBe("uuid");
|
||||||
|
|
||||||
const em = app.em;
|
const $em = app.em;
|
||||||
const mutator = em.mutator(em.entity("posts"));
|
const mutator = $em.mutator($em.entity("posts"));
|
||||||
const data = await mutator.insertOne({ title: "Hello", content: "World" });
|
const data = await mutator.insertOne({ title: "Hello", content: "World" });
|
||||||
expect(data.data.id).toBeString();
|
expect(data.data.id).toBeString();
|
||||||
expect(String(data.data.id).length).toBe(36);
|
expect(String(data.data.id).length).toBe(36);
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"$bknd/*": ["../../app/src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["./src/**/*.ts"],
|
"include": ["./src/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
|
|||||||
Reference in New Issue
Block a user