Compare commits

..

84 Commits

Author SHA1 Message Date
dswbx c9fb497c2d Merge pull request #246 from bknd-io/fix/mobile-input-zoom
style: fix mobile zoom on input focus/blur
2025-09-03 17:03:14 +02:00
dswbx ccd4eb10cb Merge pull request #245 from bknd-io/fix/cf-instance-flash
cloudflare: fixing multiple instances competing with configuration state by always serving fresh
2025-09-03 17:03:03 +02:00
dswbx 847f75b725 docs: update cloudflare imports 2025-09-03 17:00:24 +02:00
dswbx 8b4bf0d94a style: fix mobile zoom on input focus/blur 2025-09-03 08:28:27 +02:00
dswbx bf521e2931 cloudflare: fixing multiple instances competing with configuration state by always serving fresh 2025-09-03 07:54:40 +02:00
dswbx 111b2ae364 docs: fix generated mcp tools page title 2025-08-30 17:31:22 +02:00
dswbx dc08d12ce6 docs & readme: fix demo 2025-08-30 16:21:46 +02:00
dswbx ce773007f0 readme: update badges 2025-08-30 15:05:46 +02:00
dswbx e78c271c2f readme: added mcp as feature 2025-08-30 15:03:46 +02:00
dswbx 7ee07adc70 v0.17.0 2025-08-30 14:20:25 +02:00
dswbx 53e5c6fd98 Merge pull request #234 from bknd-io/release/0.17
Release 0.17
2025-08-30 14:16:37 +02:00
dswbx 4591631d3c fix e2e tests by forcely setting to be used db 2025-08-30 14:11:08 +02:00
dswbx 5a27c41516 mcp: fix tests changing new default mcp endpoint 2025-08-30 14:08:20 +02:00
dswbx 24eefa5357 mcp: added path config, register at /api path by default to work with frameworks 2025-08-30 14:06:13 +02:00
dswbx d898018b49 mcp ui: add copy on history 2025-08-30 13:41:15 +02:00
dswbx 09fdde1c5b fix mcp auth, styling fixes 2025-08-30 13:31:48 +02:00
dswbx fb0dbb9a51 fix system mcp notification console 2025-08-30 11:19:26 +02:00
dswbx 81f578895e bump version, fix cloudflare export 2025-08-30 10:20:07 +02:00
dswbx 2de12674d9 docs: mcp (#243)
* added mcp documentation

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

* updated cli help text

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

* docs: added defaults to cloudflare image plugin

* docs: updated sdk and elements
2025-08-29 12:50:23 +02:00
dswbx 5b879ac6bf Merge pull request #242 from bknd-io/feat/cli-global-options
cli: align config aware commands options
2025-08-29 09:41:52 +02:00
dswbx c04ad89a86 cli: align config aware commands options 2025-08-29 09:40:27 +02:00
dswbx 93019827b0 cli: updated config to get config from app, added sync command (#241)
* cli: updated config to get config from app, added sync command

* updated command description
2025-08-29 08:40:14 +02:00
dswbx 9436b8bac5 cloudflare: enable fs with @cloudflare/vite-plugin (#239)
* init dev vite write plugin

* reorganized cf proxy, fixed dev write
2025-08-29 06:44:20 +02:00
dswbx 8f471a29b0 json viewer: updated size display 2025-08-28 19:35:32 +02:00
dswbx dcf88cf587 mcp: improve auth id type + styling fixes 2025-08-21 10:58:31 +02:00
dswbx f9184a71db Merge pull request #225 from bknd-io/feat/mcp
initialized mcp support
2025-08-20 19:14:05 +02:00
dswbx bc1ab0f6d3 mcp: sorting tools, fixed cloudflare example, fixed some styling 2025-08-20 18:26:48 +02:00
dswbx 0b21157f92 Merge pull request #237 from bknd-io/docs/fix-nextjs-and-events
docs: fixed events, commented edge runtime for nextjs
2025-08-20 17:06:35 +02:00
dswbx cacb13e464 docs: fixed events, commented edge runtime for nextjs 2025-08-20 16:05:56 +02:00
dswbx 8ec03afbe8 fix mcp ui heights 2025-08-20 15:45:03 +02:00
dswbx ab8be017c1 updated docs mcp tools 2025-08-15 10:28:04 +02:00
dswbx 5c28511fe6 added getMcpClient to app 2025-08-15 10:27:05 +02:00
dswbx deb8aacca4 added mcp ui as tool 2025-08-15 10:12:09 +02:00
dswbx aafd333d12 fix form isRequired utils and test 2025-08-14 16:56:12 +02:00
dswbx 9e0eaf42e5 fix jsonv-ts version 2025-08-14 16:52:22 +02:00
dswbx 63254de13a added a simple mcp ui in tests 2025-08-14 16:49:31 +02:00
dswbx 9ac5fa03c6 optimized performance 2025-08-14 10:05:15 +02:00
dswbx 70f0240da5 mcp: added auth tests, updated data tests 2025-08-12 22:13:09 +02:00
dswbx a6ed74d904 added mcp tests for media 2025-08-12 20:57:13 +02:00
dswbx bd3d2ea900 added data mcp tests 2025-08-12 20:22:38 +02:00
dswbx 871cec9251 Merge remote-tracking branch 'origin/release/0.17' into feat/mcp
# Conflicts:
#	app/src/data/AppData.ts
#	app/src/data/server/query.ts
#	examples/cloudflare-worker/src/index.ts
2025-08-12 16:17:26 +02:00
dswbx 97d6af3792 cleaned up left over bknd/utils imports (#235) 2025-08-12 16:12:10 +02:00
dswbx 725a2d453a adapter(cf): fix exports and type name for do db (#232) 2025-08-12 15:30:45 +02:00
dswbx 375d2c205f adapter(cloudflare): removed durable mode, added withPlatformProxy (#233)
* removed `durable` mode as it requires an import from "cloudflare:" that often fails in non-cf environments

* remove worker configuration types

* add `withPlatformProxy`

* withPlatformProxy: make configuration optional
2025-08-12 15:29:09 +02:00
dswbx f40ea0ec5b init mcp data tests, added crud for $record 2025-08-12 12:55:14 +02:00
dswbx 1e8c373dd4 added mcp server tests 2025-08-12 09:17:38 +02:00
dswbx 2e145bbf95 init mcp tools test 2025-08-12 08:51:32 +02:00
dswbx 822e6fd644 fix tools when auth is disabled + log notifications to console 2025-08-09 15:07:20 +02:00
dswbx ad04d5273e docs: update tools, test bun 2025-08-09 14:23:11 +02:00
dswbx c1c23d24fe switch to node api for generate-mcp for cf builds 2025-08-09 14:18:44 +02:00
dswbx 06300427cb update jsonv-ts, fixed tests 2025-08-09 14:16:51 +02:00
dswbx cb873381f1 auto generated tools docs, added stdio transport, added additional mcp config and permissions 2025-08-09 14:14:51 +02:00
dswbx b5b4c5eef0 Merge pull request #231 from DimplesY/main
fix: add missing @clack/prompts dependency
2025-08-08 13:37:53 +02:00
Dimples_YJ 7af8e09468 fix: add missing @clack/prompts dependency 2025-08-08 13:51:12 +08:00
dswbx 170ea2c45b added additional permissions, implemented mcp authentication 2025-08-07 15:20:29 +02:00
dswbx 42db5f55c7 added authentication, $schema, fixed media adapter mcp 2025-08-07 11:33:46 +02:00
dswbx 1b02feca93 added mcp tools from routes 2025-08-07 08:36:12 +02:00
dswbx fd966b6aef Merge pull request #230 from stormbyte/main
Update Dockerfile to node 24
2025-08-06 20:01:54 +02:00
stormbyte 6bed2e4fca Update to node 24 2025-08-06 16:06:03 +02:00
dswbx dfb1457dfa Merge pull request #229 from DimplesY/main
docs: fix <Admin /> config props
2025-08-06 10:00:33 +02:00
Jie 4f945842a9 docs: update astro.mdx 2025-08-06 15:52:05 +08:00
Jie 44fdfcc8d9 docs: Correct the incorrect value of the config attribute in the admin component 2025-08-06 15:19:28 +08:00
dswbx 07810ff63c Merge remote-tracking branch 'origin/main' into feat/mcp
# Conflicts:
#	app/package.json
#	bun.lock
2025-08-06 08:44:46 +02:00
dswbx 143d6a55ff updated lock to 0.16.1 2025-08-06 08:31:19 +02:00
dswbx 8fa905a8f1 docs: adding poster to assets (#228)
* docs: adding poster to assets

* readme: fix poster image
2025-08-06 08:29:46 +02:00
dswbx ad0d2e6ff8 add jsonv-ts as dependency for type inference, fix media api types (#227)
* add jsonv-ts as dependency for type inference, fix media api types

* add jsonv-ts as dependency for type inference, fix media api types

* add jsonv-ts as dependency for type inference, fix media api types
2025-08-06 08:21:17 +02:00
dswbx aa0e6f90d9 examples: fixing imports due to 0.16 (#226) 2025-08-06 08:19:29 +02:00
dswbx 3e2938f77d added initial $record 2025-08-05 13:20:00 +02:00
dswbx 5e5f0ef70f fixing jsonv-ts imports 2025-08-02 16:47:24 +02:00
dswbx 104885ea5c fix tests failing because of new module ctx 2025-08-02 16:36:23 +02:00
dswbx ffbb61d58a initialized mcp support 2025-08-02 16:33:05 +02:00
dswbx bd48bb7a18 v0.16 2025-08-01 16:03:23 +02:00
dswbx a298b65abf Release 0.16 (#196)
* initial refactor

* fixes

* test secrets extraction

* updated lock

* fix secret schema

* updated schemas, fixed tests, skipping flow tests for now

* added validator for rjsf, hook form via standard schema

* removed @sinclair/typebox

* remove unneeded vite dep

* fix jsonv literal on Field.tsx

* fix schema import path

* fix schema modals

* fix schema modals

* fix json field form, replaced auth form

* initial waku

* finalize waku example

* fix jsonv-ts version

* fix schema updates with falsy values

* fix media api to respect options' init, improve types

* checking media controller test

* checking media controller test

* checking media controller test

* clean up mediacontroller test

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials` (#214)

* added cookie option `partitioned`, as well as cors `origin` to be array, option to enable `credentials`

* fix server test

* fix data api (updated jsonv-ts)

* enhance cloudflare image optimization plugin with new options and explain endpoint (#215)

* feat: add ability to serve static by using dynamic imports (#197)

* feat: add ability to serve static by using dynamic imports

* serveStaticViaImport: make manifest optional

* serveStaticViaImport: add error log

* refactor/imports (#217)

* refactored core and core/utils imports

* refactored core and core/utils imports

* refactored media imports

* refactored auth imports

* refactored data imports

* updated package json exports, fixed mm config

* fix tests

* feat/deno (#219)

* update bun version

* fix module manager's em reference

* add basic deno example

* finalize

* docs: fumadocs migration (#185)

* feat(docs): initialize documentation structure with Fumadocs

* feat(docs): remove home route and move /docs route to /route

* feat(docs): add redirect to /start page

* feat(docs): migrate Getting Started chapters

* feat(docs): migrate Usage and Extending chapters

* feat(callout): add CalloutCaution, CalloutDanger, CalloutInfo, and CalloutPositive

* feat(layout): add Discord and GitHub links to documentation layout

* feat(docs): add integration chapters draft

* feat(docs): add modules chapters draft

* refactor(mdx-components): remove unused Icon import

* refactor(StackBlitz): enhance type safety by using unknown instead of any

* refactor(layout): update navigation mode to 'top' in layout configuration

* feat(docs): add @iconify/react package

* docs(mdx-components): add Icon component to MDX components list

* feat(docs): update Next.js integration guide

* feat(docs): update React Router integration guide

* feat(docs): update Astro integration guide

* feat(docs): update Vite integration guide

* fix(docs): update package manager initialization commands

* feat(docs): migrate Modules chapters

* chore(docs): update package.json with new devDependencies

* feat(docs): migrate Integration Runtimes chapters

* feat(docs): update Database usage chapter

* feat(docs): restructure documentation paths

* chore(docs): clean up unused imports and files in documentation

* style(layout): revert navigation mode to previous state

* fix(docs): routing for documentation structure

* feat(openapi): add API documentation generation from OpenAPI schema

* feat(docs): add icons to documentation pages

* chore(dependencies): remove unused content-collections packages

* fix(types): fix type error for attachFile in source.ts

* feat(redirects): update root redirect destination to '/start'

* feat(search): add static search functionality

* chore(dependencies): update fumadocs-core and fumadocs-ui to latest versions

* feat(search): add Powered by Orama link

* feat(generate-openapi): add error handling for missing OpenAPI schema

* feat(scripts): add OpenAPI generation to build process

* feat(config): enable dynamic redirects and rewrites in development mode

* feat(layout): add GitHub token support for improved API rate limits

* feat(redirects): add 301 redirects for cloudflare pages

* feat(docs): add Vercel redirects configuration

* feat(config): enable standalone output for development environment

* chore(layout): adjust layout settings

* refactor(package): clean up ajv dependency versions

* feat(docs): add twoslash support

* refactor(layout): update DocsLayout import and navigation configuration

* chore(layout): clean up layout.tsx by commenting out GithubInfo

* fix(Search): add locale to search initialization

* chore(package): update fumadocs and orama to latest versions

* docs: add menu items descriptions

* feat(layout): add GitHub URL to the layout component

* feat(docs): add AutoTypeTable component to MDX components

* feat(app): implement AutoTypeTable rendering for AppEvents type

* docs(layout): switch callouts back to default components

* fix(config): use __filename and __dirname for module paths

* docs: add note about node.js 22 requirement

* feat(styles): add custom color variables for light and dark themes

* docs: add S3 setup instructions for media module

* docs: fix typos and indentation in media module docs

* docs: add local media adapter example for Node.js

* docs(media): add S3/R2 URL format examples and fix typo

* docs: add cross-links to initial config and seeding sections

* indent numbered lists content, clarified media serve locations

* fix mediacontroller tests

* feat(layout): add AnimatedGridPattern component for dynamic background

* style(layout): configure fancy ToC style ('clerk')

* fix(AnimatedGridPattern): correct strokeDasharray type

* docs: actualize docs

* feat: add favicon

* style(cloudflare): format code examples

* feat(layout): add Github and Discord footer icons

* feat(footer): add SVG social media icons for GitHub and Discord

* docs: adjusted auto type table, added llm functions

* added static deployment to cloudflare workers

* docs: change cf redirects to proxy *.mdx instead of redirecting

---------

Co-authored-by: dswbx <dennis.senn@gmx.ch>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>

* build: improve build script

* add missing exports, fix EntityTypescript imports

* media: Dropzone: add programmatic upload, additional events, loading state

* schema object: disable extended defaults to allow empty config values

* Feat/new docs deploy (#224)

* test

* try fixing pm

* try fixing pm

* fix docs on imports, export events correctly

---------

Co-authored-by: Tim Seriakov <59409712+timseriakov@users.noreply.github.com>
Co-authored-by: cameronapak <cameronandrewpak@gmail.com>
2025-08-01 15:55:59 +02:00
dswbx daaaae82b6 Merge pull request #203 from bknd-io/fix/auth-redirect-and-adapter-config
fix auth redirect on error and adapter app fn
2025-07-19 09:15:23 +02:00
dswbx 79ace4f1e5 Merge pull request #206 from cameronapak/main
docs: add documentation on S3 integration
2025-07-19 09:13:20 +02:00
dswbx f19977ac28 fix mediacontroller tests 2025-07-19 09:12:28 +02:00
dswbx a3782728f9 indent numbered lists content, clarified media serve locations 2025-07-19 08:39:19 +02:00
cameronapak 8b832f02ac docs: add cross-links to initial config and seeding sections 2025-07-16 23:03:38 -05:00
cameronapak cec32ae881 docs(media): add S3/R2 URL format examples and fix typo 2025-07-16 22:57:15 -05:00
cameronapak 0fffa26d55 docs: add local media adapter example for Node.js 2025-07-16 22:47:09 -05:00
cameronapak 2b6f520f46 docs: fix typos and indentation in media module docs 2025-07-16 22:41:55 -05:00
cameronapak 7379bf2b1b docs: add S3 setup instructions for media module 2025-07-16 22:38:36 -05:00
dswbx db3c2cea10 docs: add note about node.js 22 requirement 2025-07-11 12:49:35 +02:00
344 changed files with 21922 additions and 9986 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"bknd": {
"url": "http://localhost:3000/mcp",
"headers": {
"API_KEY": "value"
}
}
}
}
+3 -1
View File
@@ -31,4 +31,6 @@ packages/media/.env
.git_old .git_old
docker/tmp docker/tmp
.debug .debug
.history .history
.aider*
.vercel
+5 -4
View File
@@ -1,9 +1,9 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd) [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) ![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/public/assets/poster.png)
<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>
@@ -27,8 +27,8 @@ bknd simplifies app development by providing a fully functional backend for data
## Size ## Size
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) ![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements&t=1)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui) ![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui&t=1)
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets. 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:
+6 -6
View File
@@ -9,16 +9,16 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("adapter", () => { describe("adapter", () => {
it("makes config", () => { it("makes config", async () => {
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({}); expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual( expect(
{}, omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
); ).toEqual({});
// merges everything returned from `app` with the config // merges everything returned from `app` with the config
expect( expect(
omitKeys( omitKeys(
adapter.makeConfig( await adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) }, { app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } }, { env: { TEST: "test" } },
), ),
+18
View File
@@ -20,6 +20,7 @@ describe("App", () => {
"guard", "guard",
"flags", "flags",
"logger", "logger",
"mcp",
"helper", "helper",
]); ]);
}, },
@@ -135,4 +136,21 @@ describe("App", () => {
// expect async listeners to be executed sync after request // expect async listeners to be executed sync after request
expect(called).toHaveBeenCalled(); expect(called).toHaveBeenCalled();
}); });
test("getMcpClient", async () => {
const app = createApp({
initialConfig: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
const client = app.getMcpClient();
const res = await client.listTools();
expect(res).toBeDefined();
expect(res?.tools.length).toBeGreaterThan(0);
});
}); });
+8
View File
@@ -13,6 +13,10 @@ describe("AppServer", () => {
allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"], allow_methods: ["GET", "POST", "PATCH", "PUT", "DELETE"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
}, },
mcp: {
enabled: false,
path: "/api/system/mcp",
},
}); });
} }
@@ -31,6 +35,10 @@ describe("AppServer", () => {
allow_methods: ["GET", "POST"], allow_methods: ["GET", "POST"],
allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"], allow_headers: ["Content-Type", "Content-Length", "Authorization", "Accept"],
}, },
mcp: {
enabled: false,
path: "/api/system/mcp",
},
}); });
} }
}); });
+226
View File
@@ -0,0 +1,226 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import type { McpServer } from "bknd/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] auth_me
* - [x] auth_strategies
* - [x] auth_user_create
* - [x] auth_user_token
* - [x] auth_user_password_change
* - [x] auth_user_password_test
* - [x] config_auth_get
* - [x] config_auth_update
* - [x] config_auth_strategies_get
* - [x] config_auth_strategies_add
* - [x] config_auth_strategies_update
* - [x] config_auth_strategies_remove
* - [x] config_auth_roles_get
* - [x] config_auth_roles_add
* - [x] config_auth_roles_update
* - [x] config_auth_roles_remove
*/
describe("mcp auth", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
app = createApp({
initialConfig: {
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("auth_*", async () => {
const me = await tool(server, "auth_me", {});
expect(me.user).toBeNull();
// strategies
const strategies = await tool(server, "auth_strategies", {});
expect(Object.keys(strategies.strategies).length).toEqual(1);
expect(strategies.strategies.password.enabled).toBe(true);
// create user
const user = await tool(
server,
"auth_user_create",
{
email: "test@test.com",
password: "12345678",
},
new Headers(),
);
expect(user.email).toBe("test@test.com");
// create token
const token = await tool(
server,
"auth_user_token",
{
email: "test@test.com",
},
new Headers(),
);
expect(token.token).toBeDefined();
expect(token.user.email).toBe("test@test.com");
// me
const me2 = await tool(
server,
"auth_me",
{},
new Request("http://localhost", {
headers: new Headers({
Authorization: `Bearer ${token.token}`,
}),
}),
);
expect(me2.user.email).toBe("test@test.com");
// change password
const changePassword = await tool(
server,
"auth_user_password_change",
{
email: "test@test.com",
password: "87654321",
},
new Headers(),
);
expect(changePassword.changed).toBe(true);
// test password
const testPassword = await tool(
server,
"auth_user_password_test",
{
email: "test@test.com",
password: "87654321",
},
new Headers(),
);
expect(testPassword.valid).toBe(true);
});
test("config_auth_{get,update}", async () => {
expect(await tool(server, "config_auth_get", {})).toEqual({
path: "",
secrets: false,
partial: false,
value: app.toJSON().auth,
});
// update
await tool(server, "config_auth_update", {
value: {
allow_register: false,
},
});
expect(app.toJSON().auth.allow_register).toBe(false);
});
test("config_auth_strategies_{get,add,update,remove}", async () => {
const strategies = await tool(server, "config_auth_strategies_get", {
key: "password",
});
expect(strategies).toEqual({
secrets: false,
module: "auth",
key: "password",
value: {
enabled: true,
type: "password",
},
});
// add google oauth
const addGoogleOauth = await tool(server, "config_auth_strategies_add", {
key: "google",
value: {
type: "oauth",
enabled: true,
config: {
name: "google",
type: "oidc",
client: {
client_id: "client_id",
client_secret: "client_secret",
},
},
},
return_config: true,
});
expect(addGoogleOauth.config.google.enabled).toBe(true);
expect(app.toJSON().auth.strategies.google?.enabled).toBe(true);
// update (disable) google oauth
await tool(server, "config_auth_strategies_update", {
key: "google",
value: {
enabled: false,
},
});
expect(app.toJSON().auth.strategies.google?.enabled).toBe(false);
// remove google oauth
await tool(server, "config_auth_strategies_remove", {
key: "google",
});
expect(app.toJSON().auth.strategies.google).toBeUndefined();
});
test("config_auth_roles_{get,add,update,remove}", async () => {
// add role
const addGuestRole = await tool(server, "config_auth_roles_add", {
key: "guest",
value: {
permissions: ["read", "write"],
},
return_config: true,
});
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
// update role
await tool(server, "config_auth_roles_update", {
key: "guest",
value: {
permissions: ["read"],
},
});
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
// get role
const getGuestRole = await tool(server, "config_auth_roles_get", {
key: "guest",
});
expect(getGuestRole.value.permissions).toEqual(["read"]);
// remove role
await tool(server, "config_auth_roles_remove", {
key: "guest",
});
expect(app.toJSON().auth.roles?.guest).toBeUndefined();
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from "bun:test";
import { createApp } from "core/test/utils";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
describe("mcp", () => {
it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
auth: {
enabled: true,
},
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
expect(app.mcp?.tools.length).toBeGreaterThan(0);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { describe, test, expect, beforeEach, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { pickKeys, type McpServer } from "bknd/utils";
import { entity, text } from "bknd";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [ ] data_sync
* - [x] data_entity_fn_count
* - [x] data_entity_fn_exists
* - [x] data_entity_read_one
* - [x] data_entity_read_many
* - [x] data_entity_insert
* - [x] data_entity_update_many
* - [x] data_entity_update_one
* - [x] data_entity_delete_one
* - [x] data_entity_delete_many
* - [x] data_entity_info
* - [ ] config_data_get
* - [ ] config_data_update
* - [x] config_data_entities_get
* - [x] config_data_entities_add
* - [x] config_data_entities_update
* - [x] config_data_entities_remove
* - [x] config_data_relations_add
* - [x] config_data_relations_get
* - [x] config_data_relations_update
* - [x] config_data_relations_remove
* - [x] config_data_indices_get
* - [x] config_data_indices_add
* - [x] config_data_indices_update
* - [x] config_data_indices_remove
*/
describe("mcp data", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
const time = performance.now();
app = createApp({
initialConfig: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("config_data_entities_{add,get,update,remove}", async () => {
const result = await tool(server, "config_data_entities_add", {
key: "test",
return_config: true,
value: {},
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(result.config.test?.type).toEqual("regular");
const entities = Object.keys(app.toJSON().data.entities ?? {});
expect(entities).toContain("test");
{
// get
const result = await tool(server, "config_data_entities_get", {
key: "test",
});
expect(result.module).toBe("data");
expect(result.key).toBe("test");
expect(result.value.type).toEqual("regular");
}
{
// update
const result = await tool(server, "config_data_entities_update", {
key: "test",
return_config: true,
value: {
config: {
name: "Test",
},
},
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(result.config.test.config?.name).toEqual("Test");
expect(app.toJSON().data.entities?.test?.config?.name).toEqual("Test");
}
{
// remove
const result = await tool(server, "config_data_entities_remove", {
key: "test",
});
expect(result.success).toBe(true);
expect(result.module).toBe("data");
expect(app.toJSON().data.entities?.test).toBeUndefined();
}
});
test("config_data_relations_{add,get,update,remove}", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: {},
});
await tool(server, "config_data_entities_add", {
key: "comments",
value: {},
});
expect(Object.keys(app.toJSON().data.entities ?? {})).toEqual(["posts", "comments"]);
// create relation
await tool(server, "config_data_relations_add", {
key: "", // doesn't matter
value: {
type: "n:1",
source: "comments",
target: "posts",
},
});
const config = app.toJSON().data;
expect(
pickKeys((config.relations?.n1_comments_posts as any) ?? {}, ["type", "source", "target"]),
).toEqual({
type: "n:1",
source: "comments",
target: "posts",
});
expect(config.entities?.comments?.fields?.posts_id?.type).toBe("relation");
{
// info
const postsInfo = await tool(server, "data_entity_info", {
entity: "posts",
});
expect(postsInfo.fields).toEqual(["id"]);
expect(postsInfo.relations.all.length).toBe(1);
const commentsInfo = await tool(server, "data_entity_info", {
entity: "comments",
});
expect(commentsInfo.fields).toEqual(["id", "posts_id"]);
expect(commentsInfo.relations.all.length).toBe(1);
}
// update
await tool(server, "config_data_relations_update", {
key: "n1_comments_posts",
value: {
config: {
with_limit: 10,
},
},
});
expect((app.toJSON().data.relations?.n1_comments_posts?.config as any)?.with_limit).toBe(10);
// delete
await tool(server, "config_data_relations_remove", {
key: "n1_comments_posts",
});
expect(app.toJSON().data.relations?.n1_comments_posts).toBeUndefined();
});
test("config_data_indices_update", async () => {
expect(server.tools.map((t) => t.name).includes("config_data_indices_update")).toBe(false);
});
test("config_data_indices_{add,get,remove}", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: entity("posts", {
title: text(),
content: text(),
}).toJSON(),
});
// add index on title
await tool(server, "config_data_indices_add", {
key: "", // auto generated
value: {
entity: "posts",
fields: ["title"],
},
});
expect(app.toJSON().data.indices?.idx_posts_title).toEqual({
entity: "posts",
fields: ["title"],
unique: false,
});
// delete
await tool(server, "config_data_indices_remove", {
key: "idx_posts_title",
});
expect(app.toJSON().data.indices?.idx_posts_title).toBeUndefined();
});
test("data_entity_*", async () => {
// create posts and comments
await tool(server, "config_data_entities_add", {
key: "posts",
value: entity("posts", {
title: text(),
content: text(),
}).toJSON(),
});
await tool(server, "config_data_entities_add", {
key: "comments",
value: entity("comments", {
content: text(),
}).toJSON(),
});
// insert a few posts
for (let i = 0; i < 10; i++) {
await tool(server, "data_entity_insert", {
entity: "posts",
json: {
title: `Post ${i}`,
},
});
}
// insert a few comments
for (let i = 0; i < 5; i++) {
await tool(server, "data_entity_insert", {
entity: "comments",
json: {
content: `Comment ${i}`,
},
});
}
const result = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 5,
});
expect(result.data.length).toBe(5);
expect(result.meta.items).toBe(5);
expect(result.meta.total).toBe(10);
expect(result.data[0].title).toBe("Post 0");
{
// count
const result = await tool(server, "data_entity_fn_count", {
entity: "posts",
});
expect(result.count).toBe(10);
}
{
// exists
const res = await tool(server, "data_entity_fn_exists", {
entity: "posts",
json: {
id: result.data[0].id,
},
});
expect(res.exists).toBe(true);
const res2 = await tool(server, "data_entity_fn_exists", {
entity: "posts",
json: {
id: "123",
},
});
expect(res2.exists).toBe(false);
}
// update
await tool(server, "data_entity_update_one", {
entity: "posts",
id: result.data[0].id,
json: {
title: "Post 0 updated",
},
});
const result2 = await tool(server, "data_entity_read_one", {
entity: "posts",
id: result.data[0].id,
});
expect(result2.data.title).toBe("Post 0 updated");
// delete the second post
await tool(server, "data_entity_delete_one", {
entity: "posts",
id: result.data[1].id,
});
const result3 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 2,
});
expect(result3.data.map((p) => p.id)).toEqual([1, 3]);
// update many
await tool(server, "data_entity_update_many", {
entity: "posts",
update: {
title: "Post updated",
},
where: {
title: { $isnull: 0 },
},
});
const result4 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 10,
});
expect(result4.data.length).toBe(9);
expect(result4.data.map((p) => p.title)).toEqual(
Array.from({ length: 9 }, () => "Post updated"),
);
// delete many
await tool(server, "data_entity_delete_many", {
entity: "posts",
json: {
title: { $isnull: 0 },
},
});
const result5 = await tool(server, "data_entity_read_many", {
entity: "posts",
limit: 10,
});
expect(result5.data.length).toBe(0);
expect(result5.meta.items).toBe(0);
expect(result5.meta.total).toBe(0);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { describe, test, expect, beforeAll, beforeEach, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import type { McpServer } from "bknd/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] config_media_get
* - [x] config_media_update
* - [x] config_media_adapter_get
* - [x] config_media_adapter_update
*/
describe("mcp media", async () => {
let app: App;
let server: McpServer;
beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter);
app = createApp({
initialConfig: {
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
console.dir(message, { depth: null });
});
});
const tool = createMcpToolCaller();
test("config_media_{get,update}", async () => {
const result = await tool(server, "config_media_get", {});
expect(result).toEqual({
path: "",
secrets: false,
partial: false,
value: app.toJSON().media,
});
// partial
expect((await tool(server, "config_media_get", { path: "adapter" })).value).toEqual({
type: "local",
config: {
path: "./",
},
});
// update
await tool(server, "config_media_update", {
value: {
storage: {
body_max_size: 1024 * 1024 * 10,
},
},
return_config: true,
});
expect(app.toJSON().media.storage.body_max_size).toBe(1024 * 1024 * 10);
});
test("config_media_adapter_{get,update}", async () => {
const result = await tool(server, "config_media_adapter_get", {});
expect(result).toEqual({
secrets: false,
value: app.toJSON().media.adapter,
});
// update
await tool(server, "config_media_adapter_update", {
value: {
type: "local",
config: {
path: "./subdir",
},
},
});
const adapter = app.toJSON().media.adapter as any;
expect(adapter.config.path).toBe("./subdir");
expect(adapter.type).toBe("local");
// set to s3
{
await tool(server, "config_media_adapter_update", {
value: {
type: "s3",
config: {
access_key: "123",
secret_access_key: "456",
url: "https://example.com/what",
},
},
});
const adapter = app.toJSON(true).media.adapter as any;
expect(adapter.type).toBe("s3");
expect(adapter.config.url).toBe("https://example.com/what");
}
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
/**
* - [x] config_server_get
* - [x] config_server_update
*/
describe("mcp system", async () => {
let app: App;
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
server = app.mcp!;
});
const tool = createMcpToolCaller();
test("config_server_get", async () => {
const result = await tool(server, "config_server_get", {});
expect(JSON.parse(JSON.stringify(result))).toEqual({
path: "",
secrets: false,
partial: false,
value: JSON.parse(JSON.stringify(app.toJSON().server)),
});
});
test("config_server_get2", async () => {
const result = await tool(server, "config_server_get", {});
expect(JSON.parse(JSON.stringify(result))).toEqual({
path: "",
secrets: false,
partial: false,
value: JSON.parse(JSON.stringify(app.toJSON().server)),
});
});
test("config_server_update", async () => {
const original = JSON.parse(JSON.stringify(app.toJSON().server));
const result = await tool(server, "config_server_update", {
value: {
cors: {
origin: "http://localhost",
},
},
return_config: true,
});
expect(JSON.parse(JSON.stringify(result))).toEqual({
success: true,
module: "server",
config: {
...original,
cors: {
...original.cors,
origin: "http://localhost",
},
},
});
expect(app.toJSON().server.cors.origin).toBe("http://localhost");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
/**
* - [x] system_config
* - [x] system_build
* - [x] system_ping
* - [x] system_info
*/
describe("mcp system", async () => {
let app: App;
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
server: {
mcp: {
enabled: true,
},
},
},
});
await app.build();
server = app.mcp!;
});
const tool = createMcpToolCaller();
test("system_ping", async () => {
const result = await tool(server, "system_ping", {});
expect(result).toEqual({ pong: true });
});
test("system_info", async () => {
const result = await tool(server, "system_info", {});
expect(Object.keys(result).length).toBeGreaterThan(0);
expect(Object.keys(result)).toContainValues(["version", "runtime", "connection"]);
});
test("system_build", async () => {
const called = mock(() => null);
app.emgr.onEvent(AppEvents.AppBuiltEvent, () => void called(), { once: true });
const result = await tool(server, "system_build", {});
expect(called).toHaveBeenCalledTimes(1);
expect(result.success).toBe(true);
});
test("system_config", async () => {
const result = await tool(server, "system_config", {});
expect(result).toEqual(app.toJSON());
});
});
@@ -1,66 +0,0 @@
import { AuthStrategy, registries, type Authenticator } from "bknd";
import { describe, it, expect } from "bun:test";
import { createApp } from "core/test/utils";
import { s } from "bknd/utils";
import { Hono } from "hono";
const customStrategySchema = s.object({
name: s.string(),
});
type CustomStrategyOptions = s.Static<typeof customStrategySchema>;
class CustomStrategy extends AuthStrategy<typeof customStrategySchema> {
constructor(config: Partial<CustomStrategyOptions> = {}) {
super(config as any, "custom", "custom", "form");
}
getSchema() {
return customStrategySchema;
}
getController(authenticator: Authenticator) {
return new Hono()
.post("/login", async (c) => {
try {
// do magic, and it succeeds, resolve login
// @ts-ignore
return await authenticator.resolveLogin();
} catch (e) {
// in case of error, respond with the error
return authenticator.respondWithError(c, e as any);
}
})
.post("/register", async (c) => {
try {
// do magic, and it succeeds, resolve register
// @ts-ignore
return await authenticator.resolveRegister();
} catch (e) {
// in case of error, respond with the error
return authenticator.respondWithError(c, e as any);
}
});
}
}
describe("custom auth strategy", () => {
it("should be registered", async () => {
registries.auth.register("custom", CustomStrategy);
const app = createApp({
initialConfig: {
auth: {
enabled: true,
},
},
});
await app.build();
const strategies = app
.getSchema()
.auth.properties.strategies.additionalProperties.anyOf.map((s) => s.properties.type.const);
expect(strategies).toContain("custom");
});
});
-4
View File
@@ -47,8 +47,4 @@ describe("[data] Entity", async () => {
entity.addField(field); entity.addField(field);
expect(entity.getField("new_field")).toBe(field); expect(entity.getField("new_field")).toBe(field);
}); });
test.only("types", async () => {
console.log(entity.toTypes());
});
}); });
@@ -0,0 +1,24 @@
import { describe, it, expect } from "bun:test";
import * as sDirect from "jsonv-ts";
import { s as sFromBknd } from "bknd/utils";
describe("jsonv-ts resolution", () => {
it("should resolve to a single instance", () => {
const sameNamespace = sDirect === (sFromBknd as unknown as typeof sDirect);
// If this fails, two instances are being loaded via different specifiers/paths
expect(sameNamespace).toBe(true);
});
it("should resolve specifiers to a single package path", async () => {
const base = await import.meta.resolve("jsonv-ts");
const hono = await import.meta.resolve("jsonv-ts/hono");
const mcp = await import.meta.resolve("jsonv-ts/mcp");
expect(typeof base).toBe("string");
expect(typeof hono).toBe("string");
expect(typeof mcp).toBe("string");
// They can be different files (subpath exports), but they should share the same package root
const pkgRoot = (p: string) => p.slice(0, p.lastIndexOf("jsonv-ts") + "jsonv-ts".length);
expect(pkgRoot(base)).toBe(pkgRoot(hono));
expect(pkgRoot(base)).toBe(pkgRoot(mcp));
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { format as sqlFormat } from "sql-formatter";
import type { em as protoEm } from "../src/data/prototype"; import type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises"; import { writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { slugify } from "core/utils/strings"; import { slugify } from "bknd/utils";
import { type Connection, SqliteLocalConnection } from "data/connection"; import { type Connection, SqliteLocalConnection } from "data/connection";
import { EntityManager } from "data/entities/EntityManager"; import { EntityManager } from "data/entities/EntityManager";
+2 -1
View File
@@ -2,12 +2,12 @@ import { beforeEach, describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { Guard } from "auth/authorize/Guard"; import { Guard } from "auth/authorize/Guard";
import { DebugLogger } from "core/utils/DebugLogger";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { EntityManager } from "data/entities/EntityManager"; import { EntityManager } from "data/entities/EntityManager";
import { Module, type ModuleBuildContext } from "modules/Module"; import { Module, type ModuleBuildContext } from "modules/Module";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { ModuleHelper } from "modules/ModuleHelper"; import { ModuleHelper } from "modules/ModuleHelper";
import { DebugLogger, McpServer } from "bknd/utils";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext { export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
const { dummyConnection } = getDummyConnection(); const { dummyConnection } = getDummyConnection();
@@ -19,6 +19,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
guard: new Guard(), guard: new Guard(),
flags: Module.ctx_flags, flags: Module.ctx_flags,
logger: new DebugLogger(false), logger: new DebugLogger(false),
mcp: new McpServer(),
...overrides, ...overrides,
}; };
return { return {
+3 -1
View File
@@ -102,7 +102,9 @@ describe("json form", () => {
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][]; ] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) { for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output); expect(utils.isRequired(new Draft2019(schema), pointer, schema), `${pointer} `).toBe(
output,
);
} }
}); });
+20 -1
View File
@@ -1,6 +1,24 @@
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors"; import c from "picocolors";
import { formatNumber } from "core/utils"; import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild";
if (process.env.DEBUG) {
await esbuild.build({
entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli",
platform: "node",
minify: false,
format: "esm",
bundle: true,
external: ["jsonv-ts", "jsonv-ts/*"],
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
process.exit(0);
}
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"], entrypoints: ["./src/cli/index.ts"],
@@ -8,6 +26,7 @@ const result = await Bun.build({
outdir: "./dist/cli", outdir: "./dist/cli",
env: "PUBLIC_*", env: "PUBLIC_*",
minify: true, minify: true,
external: ["jsonv-ts", "jsonv-ts/*"],
define: { define: {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
+89 -74
View File
@@ -1,6 +1,7 @@
import { $ } from "bun"; import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" }; import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -9,6 +10,14 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap"); const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean"); const clean = args.includes("--clean");
// silence tsup
const oldConsole = {
log: console.log,
warn: console.warn,
};
console.log = () => {};
console.warn = () => {};
const define = { const define = {
__isDev: "0", __isDev: "0",
__version: JSON.stringify(pkg.version), __version: JSON.stringify(pkg.version),
@@ -27,11 +36,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.info("Types built"); oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.info("Types aliased"); oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false; types_running = false;
}, },
}); });
@@ -39,6 +48,10 @@ function buildTypes() {
}); });
} }
if (types && !watch) {
buildTypes();
}
let watcher_timeout: any; let watcher_timeout: any;
function delayTypes() { function delayTypes() {
if (!watch || !types) return; if (!watch || !types) return;
@@ -48,17 +61,6 @@ function delayTypes() {
watcher_timeout = setTimeout(buildTypes, 1000); watcher_timeout = setTimeout(buildTypes, 1000);
} }
if (types && !watch) {
buildTypes();
}
function banner(title: string) {
console.info("");
console.info("=".repeat(40));
console.info(title.toUpperCase());
console.info("-".repeat(40));
}
// collection of always-external packages // collection of always-external packages
const external = [ const external = [
"bun:test", "bun:test",
@@ -67,13 +69,14 @@ const external = [
"@libsql/client", "@libsql/client",
"bknd", "bknd",
/^bknd\/.*/, /^bknd\/.*/,
"jsonv-ts",
/^jsonv-ts\/.*/,
] as const; ] as const;
/** /**
* Building backend and general API * Building backend and general API
*/ */
async function buildApi() { async function buildApi() {
banner("Building API");
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
@@ -92,6 +95,7 @@ async function buildApi() {
}, },
onSuccess: async () => { onSuccess: async () => {
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built"));
}, },
}); });
} }
@@ -135,7 +139,6 @@ async function buildUi() {
}, },
} satisfies tsup.Options; } satisfies tsup.Options;
banner("Building UI");
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
@@ -143,10 +146,10 @@ async function buildUi() {
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/index.js"); await rewriteClient("./dist/ui/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
}, },
}); });
banner("Building Client");
await tsup.build({ await tsup.build({
...base, ...base,
entry: ["src/ui/client/index.ts"], entry: ["src/ui/client/index.ts"],
@@ -154,6 +157,7 @@ async function buildUi() {
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js"); await rewriteClient("./dist/ui/client/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
}, },
}); });
} }
@@ -164,7 +168,6 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client" * - ui/client is external, and after built replaced with "bknd/client"
*/ */
async function buildUiElements() { async function buildUiElements() {
banner("Building UI Elements");
await tsup.build({ await tsup.build({
minify, minify,
sourcemap, sourcemap,
@@ -198,6 +201,7 @@ async function buildUiElements() {
onSuccess: async () => { onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js"); await rewriteClient("./dist/ui/elements/index.js");
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
}, },
}); });
} }
@@ -218,6 +222,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
splitting: false, splitting: false,
onSuccess: async () => { onSuccess: async () => {
delayTypes(); delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
}, },
...overrides, ...overrides,
define: { define: {
@@ -236,65 +241,75 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
} }
async function buildAdapters() { async function buildAdapters() {
banner("Building Adapters"); await Promise.all([
// base adapter handles // base adapter handles
await tsup.build({ tsup.build({
...baseConfig(""), ...baseConfig(""),
entry: ["src/adapter/index.ts"], entry: ["src/adapter/index.ts"],
outDir: "dist/adapter", outDir: "dist/adapter",
}); }),
// specific adatpers // specific adatpers
await tsup.build(baseConfig("react-router")); tsup.build(baseConfig("react-router")),
await tsup.build( tsup.build(
baseConfig("bun", { baseConfig("bun", {
external: [/^bun\:.*/],
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
}),
tsup.build({
...baseConfig("nextjs"),
platform: "node",
}),
tsup.build({
...baseConfig("node"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/], external: [/^bun\:.*/],
}), }),
); ]);
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
await tsup.build(baseConfig("cloudflare"));
await tsup.build({
...baseConfig("vite"),
platform: "node",
});
await tsup.build({
...baseConfig("nextjs"),
platform: "node",
});
await tsup.build({
...baseConfig("node"),
platform: "node",
});
await tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
});
await tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
});
await tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
});
} }
await buildApi(); await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
await buildUi();
await buildUiElements();
await buildAdapters();
+2 -1
View File
@@ -2,4 +2,5 @@
#registry = "http://localhost:4873" #registry = "http://localhost:4873"
[test] [test]
coverageSkipTestFiles = true coverageSkipTestFiles = true
console.depth = 10
+35
View File
@@ -0,0 +1,35 @@
import { createApp } from "bknd/adapter/bun";
async function generate() {
console.info("Generating MCP documentation...");
const app = await createApp({
initialConfig: {
server: {
mcp: {
enabled: true,
},
},
auth: {
enabled: true,
},
media: {
enabled: true,
adapter: {
type: "local",
config: {
path: "./",
},
},
},
},
});
await app.build();
const res = await app.server.request("/mcp?explain=1");
const { tools, resources } = await res.json();
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
console.info("MCP documentation generated.");
}
void generate();
+15 -8
View File
@@ -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.0-rc.0", "version": "0.17.0",
"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": {
@@ -39,11 +39,12 @@
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail", "test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage", "test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest:coverage": "vitest run --coverage", "test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test", "test:e2e": "VITE_DB_URL=:memory: playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts", "test:e2e:adapters": "VITE_DB_URL=:memory: 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"
}, },
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
@@ -64,7 +65,8 @@
"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",
"kysely": "^0.27.6", "jsonv-ts": "0.8.2",
"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",
@@ -74,6 +76,7 @@
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.758.0", "@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2", "@bluwy/giget-core": "^0.1.2",
"@clack/prompts": "^0.11.0",
"@cloudflare/vitest-pool-workers": "^0.8.38", "@cloudflare/vitest-pool-workers": "^0.8.38",
"@cloudflare/workers-types": "^4.20250606.0", "@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
@@ -100,7 +103,6 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0", "jsdom": "^26.0.0",
"jsonv-ts": "^0.3.2",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1", "kysely-generic-sqlite": "^1.2.1",
"libsql-stateless-easy": "^1.8.0", "libsql-stateless-easy": "^1.8.0",
@@ -195,6 +197,11 @@
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.js" "require": "./dist/adapter/cloudflare/index.js"
}, },
"./adapter/cloudflare/proxy": {
"types": "./dist/types/adapter/cloudflare/proxy.d.ts",
"import": "./dist/adapter/cloudflare/proxy.js",
"require": "./dist/adapter/cloudflare/proxy.js"
},
"./adapter": { "./adapter": {
"types": "./dist/types/adapter/index.d.ts", "types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js" "import": "./dist/adapter/index.js"
+1 -1
View File
@@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt"; import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi"; import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi"; import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils"; import { omitKeys } from "bknd/utils";
import type { BaseModuleApiOptions } from "modules"; import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser; export type TApiUser = SafeUser;
+49 -4
View File
@@ -1,5 +1,5 @@
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core/utils"; import { $console, McpClient } from "bknd/utils";
import { Event } from "core/events"; import { Event } from "core/events";
import type { em as prototypeEm } from "data/prototype"; import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection"; import { Connection } from "data/connection/Connection";
@@ -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;
@@ -40,6 +61,9 @@ export class AppConfigUpdatedEvent extends AppEvent<{
}> { }> {
static override slug = "app-config-updated"; static override slug = "app-config-updated";
} }
/**
* @type {Event<{ app: App }>}
*/
export class AppBuiltEvent extends AppEvent { export class AppBuiltEvent extends AppEvent {
static override slug = "app-built"; static override slug = "app-built";
} }
@@ -71,6 +95,9 @@ export type AppOptions = {
}; };
}; };
export type CreateAppConfig = { export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string }; connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
options?: AppOptions; options?: AppOptions;
@@ -90,6 +117,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
private trigger_first_boot = false; private trigger_first_boot = false;
private _building: boolean = false; private _building: boolean = false;
private _systemController: SystemController | null = null;
constructor( constructor(
public connection: C, public connection: C,
@@ -162,11 +190,12 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
if (options?.sync) this.modules.ctx().flags.sync_required = true; if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch }); await this.modules.build({ fetch: options?.fetch });
const { guard, server } = this.modules.ctx(); const { guard } = this.modules.ctx();
// load system controller // load system controller
guard.registerPermissions(Object.values(SystemPermissions)); guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController()); this._systemController = new SystemController(this);
this._systemController.register(this);
// emit built event // emit built event
$console.log("App built"); $console.log("App built");
@@ -198,6 +227,10 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return this.modules.ctx().em; return this.modules.ctx().em;
} }
get mcp() {
return this._systemController?._mcpServer;
}
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
return this.server.fetch as any; return this.server.fetch as any;
} }
@@ -256,6 +289,18 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher }); return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
} }
getMcpClient() {
if (!this.mcp) {
throw new Error("MCP is not enabled");
}
const mcpPath = this.modules.get("server").config.mcp.path;
return new McpClient({
url: "http://localhost" + mcpPath,
fetch: this.server.request,
});
}
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) { async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
// if the EventManager was disabled, we assume we shouldn't // if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated". // respond to events, such as "onUpdated".
+2 -2
View File
@@ -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);
+6 -2
View File
@@ -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),
+5 -2
View File
@@ -1,3 +1,5 @@
import { inspect } from "node:util";
export type BindingTypeMap = { export type BindingTypeMap = {
D1Database: D1Database; D1Database: D1Database;
KVNamespace: KVNamespace; KVNamespace: KVNamespace;
@@ -13,8 +15,9 @@ export function getBindings<T extends GetBindingType>(env: any, type: T): Bindin
for (const key in env) { for (const key in env) {
try { try {
if ( if (
env[key] && (env[key] as any).constructor.name === type ||
((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`) String(env[key]) === `[object ${type}]` ||
inspect(env[key]).includes(type)
) { ) {
bindings.push({ bindings.push({
key, key,
@@ -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);
@@ -18,7 +17,7 @@ describe("cf adapter", () => {
}); });
it("makes config", async () => { it("makes config", async () => {
const staticConfig = makeConfig( const staticConfig = await makeConfig(
{ {
connection: { url: DB_URL }, connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } }, initialConfig: { data: { basepath: DB_URL } },
@@ -28,7 +27,7 @@ describe("cf adapter", () => {
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } }); expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined(); expect(staticConfig.connection).toBeDefined();
const dynamicConfig = makeConfig( const dynamicConfig = await makeConfig(
{ {
app: (env) => ({ app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } }, initialConfig: { data: { basepath: env.DB_URL } },
@@ -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,11 +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 { $console } from "bknd/utils";
import { getDurable } from "./modes/durable"; import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { App } from "bknd"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
import { $console } from "core/utils";
declare global { declare global {
namespace Cloudflare { namespace Cloudflare {
@@ -17,12 +16,10 @@ 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" | "durable"; bindings?: (args: Env) => MaybePromise<{
bindings?: (args: Env) => {
kv?: KVNamespace; kv?: KVNamespace;
dobj?: DurableObjectNamespace;
db?: D1Database; db?: D1Database;
}; }>;
d1?: { d1?: {
session?: boolean; session?: boolean;
transport?: "header" | "cookie"; transport?: "header" | "cookie";
@@ -36,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> = {},
@@ -79,25 +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;
case "durable":
return await getDurable(config, context);
default:
throw new Error(`Unknown mode ${mode}`);
}
return app.fetch(request, env, ctx); return app.fetch(request, env, ctx);
}, },
+11 -11
View File
@@ -8,8 +8,8 @@ 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 "core/utils"; import { $console } from "bknd/utils";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
export const constants = { export const constants = {
@@ -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();
@@ -89,9 +89,9 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
} }
let media_registered: boolean = false; let media_registered: boolean = false;
export 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") {
@@ -102,7 +102,7 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
media_registered = true; media_registered = true;
} }
const appConfig = makeAdapterConfig(config, args?.env); const appConfig = await makeAdapterConfig(config, args?.env);
// if connection instance is given, don't do anything // if connection instance is given, don't do anything
// other than checking if D1 session is defined // other than checking if D1 session is defined
@@ -115,12 +115,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
} }
// if connection is given, try to open with unified sqlite adapter // if connection is given, try to open with unified sqlite adapter
} else if (appConfig.connection) { } else if (appConfig.connection) {
appConfig.connection = sqlite(appConfig.connection); appConfig.connection = sqlite(appConfig.connection) as any;
// if connection is not given, but env is set // if connection is not given, but env is set
// try to make D1 from bindings // try to make D1 from bindings
} else if (args?.env) { } else if (args?.env) {
const bindings = config.bindings?.(args?.env); const bindings = await config.bindings?.(args?.env);
const sessionHelper = d1SessionHelper(config); const sessionHelper = d1SessionHelper(config);
const sessionId = sessionHelper.get(args.request); const sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined; let session: D1DatabaseSession | undefined;
@@ -3,16 +3,16 @@
import { genericSqlite, type GenericSqliteConnection } from "bknd"; import { genericSqlite, type GenericSqliteConnection } from "bknd";
import type { QueryResult } from "kysely"; import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>; export type DoSqliteConnection = GenericSqliteConnection<DurableObjectState["storage"]["sql"]>;
export type DurableObjecSql = DurableObjectState["storage"]["sql"]; export type DurableObjecSql = DurableObjectState["storage"]["sql"];
export type D1ConnectionConfig<DB extends DurableObjecSql> = export type DoConnectionConfig<DB extends DurableObjecSql> =
| DurableObjectState | DurableObjectState
| { | {
sql: DB; sql: DB;
}; };
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) { export function doSqlite<DB extends DurableObjecSql>(config: DoConnectionConfig<DB>) {
const db = "sql" in config ? config.sql : config.storage.sql; const db = "sql" in config ? config.sql : config.storage.sql;
return genericSqlite( return genericSqlite(
@@ -21,7 +21,7 @@ export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<
(utils) => { (utils) => {
// must be async to work with the miniflare mock // must be async to work with the miniflare mock
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) => const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
await db.exec(sql, ...(parameters || [])); db.exec(sql, ...(parameters || []));
const mapResult = ( const mapResult = (
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>, cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
+9 -5
View File
@@ -1,10 +1,13 @@
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,
export { DurableBkndApp, getDurable } from "./modes/durable"; type CloudflareEnv,
type CloudflareBkndConfig,
} from "./cloudflare-workers.adapter";
export { d1Sqlite, type D1ConnectionConfig }; export { d1Sqlite, type D1ConnectionConfig };
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
export { export {
getBinding, getBinding,
getBindings, getBindings,
@@ -12,9 +15,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 { 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 } = 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;
}
-134
View File
@@ -1,134 +0,0 @@
import { DurableObject } from "cloudflare:workers";
import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core/utils";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
) {
const { dobj } = config.bindings?.(ctx.env)!;
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
}
const start = performance.now();
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = makeConfig(config, ctx.env);
const res = await stub.fire(ctx.request, {
config: create_config,
keepAliveSeconds: config.keepAliveSeconds,
});
const headers = new Headers(res.headers);
headers.set("X-TTDO", String(performance.now() - start));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html?: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
},
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if (
config?.connection &&
"type" in config.connection &&
config.connection.type === "libsql"
) {
//config.connection.config.protocol = "wss";
}
this.app = await createRuntimeApp({
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, this.ctx);
app.modules.server.get(constants.do_endpoint, async (c) => {
// @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({
id: this.id,
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
colo: context.colo,
});
});
await this.onBuilt(app);
},
adminOptions: { html: options.html },
beforeBuild: async (app) => {
await this.beforeBuild(app);
},
});
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
headers.set("X-DO-ID", this.id);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
async onBuilt(app: App) {}
async beforeBuild(app: App) {}
protected keepAlive(seconds: number) {
if (this.interval) {
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
// ping every 30 seconds
} else if (i % 30 === 0) {
console.log("ping");
this.app?.modules.ctx().connection.ping();
}
}, 1000);
}
}
-29
View File
@@ -1,29 +0,0 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(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,
);
}
+67
View File
@@ -0,0 +1,67 @@
import {
d1Sqlite,
getBinding,
registerMedia,
type CloudflareBkndConfig,
type CloudflareEnv,
} from "bknd/adapter/cloudflare";
import type { PlatformProxy } from "wrangler";
import process from "node:process";
export type WithPlatformProxyOptions = {
/**
* By default, proxy is used if the PROXY environment variable is set to 1.
* You can override/force this by setting this option.
*/
useProxy?: boolean;
};
export function withPlatformProxy<Env extends CloudflareEnv>(
config?: CloudflareBkndConfig<Env>,
opts?: WithPlatformProxyOptions,
) {
const use_proxy =
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
let proxy: PlatformProxy | undefined;
async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) {
if (!proxy) {
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
proxy = await getPlatformProxy();
setTimeout(proxy?.dispose, 1000);
}
return proxy.env as unknown as Env;
}
return env || ({} as Env);
}
return {
...config,
beforeBuild: async (app, registries) => {
if (!use_proxy) return;
const env = await getEnv();
registerMedia(env, registries as any);
await config?.beforeBuild?.(app, registries);
},
bindings: async (env) => {
return (await config?.bindings?.(await getEnv(env))) || {};
},
// @ts-ignore
app: async (_env) => {
const env = await getEnv(_env);
if (config?.app === undefined && use_proxy) {
const binding = getBinding(env, "D1Database");
return {
connection: d1Sqlite({
binding: binding.value,
}),
};
} else if (typeof config?.app === "function") {
return config?.app(env);
}
return config?.app || {};
},
} satisfies CloudflareBkndConfig<Env>;
}
@@ -1,4 +1,4 @@
import { registries, isDebug, guessMimeType } from "bknd"; import { registries as $registries, isDebug, guessMimeType } from "bknd";
import { getBindings } from "../bindings"; import { getBindings } from "../bindings";
import { s } from "bknd/utils"; import { s } from "bknd/utils";
import { StorageAdapter, type FileBody } from "bknd"; import { StorageAdapter, type FileBody } from "bknd";
@@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) {
); );
} }
export function registerMedia(env: Record<string, any>) { export function registerMedia(
env: Record<string, any>,
registries: typeof $registries = $registries,
) {
const r2_bindings = getBindings(env, "R2Bucket"); const r2_bindings = getBindings(env, "R2Bucket");
registries.media.register( registries.media.register(
+135
View File
@@ -0,0 +1,135 @@
import type { Plugin } from "vite";
import { writeFile as nodeWriteFile } from "node:fs/promises";
import { resolve } from "node:path";
/**
* Vite plugin that provides Node.js filesystem access during development
* by injecting a polyfill into the SSR environment
*/
export function devFsVitePlugin({
verbose = false,
configFile = "bknd.config.ts",
}: {
verbose?: boolean;
configFile?: string;
} = {}): any {
let isDev = false;
let projectRoot = "";
return {
name: "dev-fs-plugin",
enforce: "pre",
configResolved(config) {
isDev = config.command === "serve";
projectRoot = config.root;
},
configureServer(server) {
if (!isDev) return;
// Intercept stdout to watch for our write requests
const originalStdoutWrite = process.stdout.write;
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
const output = chunk.toString();
// Check if this output contains our special write request
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) {
try {
// Extract the JSON from the log line
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/);
if (match) {
const writeRequest = JSON.parse(match[1]);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
if (verbose) {
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
}
// Process the write request immediately
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
await nodeWriteFile(fullPath, writeRequest.data);
if (verbose) {
console.debug("[dev-fs-plugin] File written successfully!");
}
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Don't output the raw write request to console
return true;
}
}
} catch (error) {
// Not a valid write request, continue with normal output
}
}
// @ts-ignore
// biome-ignore lint:
return originalStdoutWrite.apply(process.stdout, arguments);
};
// Restore stdout when server closes
server.httpServer?.on("close", () => {
process.stdout.write = originalStdoutWrite;
});
},
// @ts-ignore
transform(code, id, options) {
// Only transform in SSR mode during development
if (!isDev || !options?.ssr) return;
// Check if this is the bknd config file
if (id.includes(configFile)) {
if (verbose) {
console.debug("[dev-fs-plugin] Transforming", configFile);
}
// Inject our filesystem polyfill at the top of the file
const polyfill = `
// Dev-fs polyfill injected by vite-plugin-dev-fs
if (typeof globalThis !== 'undefined') {
globalThis.__devFsPolyfill = {
writeFile: async (filename, data) => {
${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""}
// Use console logging as a communication channel
// The main process will watch for this specific log pattern
const writeRequest = {
type: 'DEV_FS_WRITE_REQUEST',
filename: filename,
data: data,
timestamp: Date.now()
};
// Output as a specially formatted console message
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest));
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""}
return Promise.resolve();
}
};
}
`;
return polyfill + code;
}
},
} satisfies Plugin;
}
// Write function that uses the dev-fs polyfill injected by our Vite plugin
export async function devFsWrite(filename: string, data: string): Promise<void> {
try {
// Check if the dev-fs polyfill is available (injected by our Vite plugin)
if (typeof globalThis !== "undefined" && (globalThis as any).__devFsPolyfill) {
return (globalThis as any).__devFsPolyfill.writeFile(filename, data);
}
// Fallback to Node.js fs for other environments (Node.js, Bun)
const { writeFile } = await import("node:fs/promises");
return writeFile(filename, data);
} catch (error) {
console.error("[dev-fs-write] Error writing file:", error);
}
}
+24 -12
View File
@@ -1,13 +1,21 @@
import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd"; import {
config as $config,
App,
type CreateAppConfig,
Connection,
guessMimeType,
type MaybePromise,
registries as $registries,
} from "bknd";
import { $console } from "bknd/utils"; import { $console } from "bknd/utils";
import type { Context, MiddlewareHandler, Next } from "hono"; import type { Context, MiddlewareHandler, Next } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite"; import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => CreateAppConfig); app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>);
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>; beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; };
@@ -30,10 +38,10 @@ export type DefaultArgs = {
[key: string]: any; [key: string]: any;
}; };
export function makeConfig<Args = DefaultArgs>( export async function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>, config: BkndConfig<Args>,
args?: Args, args?: Args,
): CreateAppConfig { ): Promise<CreateAppConfig> {
let additionalConfig: CreateAppConfig = {}; let additionalConfig: CreateAppConfig = {};
const { app, ...rest } = config; const { app, ...rest } = config;
if (app) { if (app) {
@@ -41,7 +49,7 @@ export function makeConfig<Args = DefaultArgs>(
if (!args) { if (!args) {
throw new Error("args is required when config.app is a function"); throw new Error("args is required when config.app is a function");
} }
additionalConfig = app(args); additionalConfig = await app(args);
} else { } else {
additionalConfig = app; additionalConfig = app;
} }
@@ -60,7 +68,7 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
const id = opts?.id ?? "app"; const id = opts?.id ?? "app";
let app = apps.get(id); let app = apps.get(id);
if (!app || opts?.force) { if (!app || opts?.force) {
const appConfig = makeConfig(config, args); const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) { if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined; let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) { if (Connection.isConnection(config.connection)) {
@@ -68,15 +76,19 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
} else { } else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite; const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" }; const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf); connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url); $console.info(`Using ${connection!.name} connection`, conf.url);
} }
appConfig.connection = connection; appConfig.connection = connection;
} }
app = App.create(appConfig); app = App.create(appConfig);
apps.set(id, app);
if (!opts?.force) {
apps.set(id, app);
}
} }
return app; return app;
} }
@@ -98,7 +110,7 @@ export async function createFrameworkApp<Args = DefaultArgs>(
); );
} }
await config.beforeBuild?.(app); await config.beforeBuild?.(app, $registries);
await app.build(config.buildConfig); await app.build(config.buildConfig);
} }
@@ -131,7 +143,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
"sync", "sync",
); );
await config.beforeBuild?.(app); await config.beforeBuild?.(app, $registries);
await app.build(config.buildConfig); await app.build(config.buildConfig);
} }
@@ -168,7 +180,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
const path = c.req.path.substring(1); const path = c.req.path.substring(1);
if (files.includes(path)) { if (files.includes(path)) {
try { try {
const content = await import(`bknd/static/${path}?raw`, { const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
assert: { type: "text" }, assert: { type: "text" },
}).then((m) => m.default); }).then((m) => m.default);
+33 -11
View File
@@ -1,24 +1,20 @@
import type { DB } from "bknd"; import type { DB, PrimaryFieldType } from "bknd";
import * as AuthPermissions from "auth/auth-permissions"; import * as AuthPermissions from "auth/auth-permissions";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy"; import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { $console, secureRandomString, transformObject } from "core/utils"; import { $console, secureRandomString, transformObject } from "bknd/utils";
import type { Entity, EntityManager } from "data/entities"; import type { Entity, EntityManager } from "data/entities";
import { em, entity, enumm, type FieldSchema } from "data/prototype"; import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
import { import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
type AppAuthSchema,
authConfigSchema,
AuthStrategyRegistry,
buildAuthSchema,
} from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool"; import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config"; import type { AppEntity } from "core/config";
import { usersFields } from "./auth-entities"; 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 {}
@@ -73,8 +69,7 @@ export class AppAuth extends Module<AppAuthSchema> {
// build strategies // build strategies
const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => { const strategies = transformObject(this.config.strategies ?? {}, (strategy, name) => {
try { try {
const cls = AuthStrategyRegistry.get(strategy.type as any).cls; return new STRATEGIES[strategy.type].cls(strategy.config as any);
return new cls(strategy.config as any);
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Could not build strategy ${String( `Could not build strategy ${String(
@@ -93,6 +88,7 @@ export class AppAuth extends Module<AppAuthSchema> {
super.setBuilt(); super.setBuilt();
this._controller = new AuthController(this); this._controller = new AuthController(this);
this._controller.registerMcp();
this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(AuthPermissions); this.ctx.guard.registerPermissions(AuthPermissions);
} }
@@ -114,7 +110,7 @@ export class AppAuth extends Module<AppAuthSchema> {
} }
getSchema() { getSchema() {
return buildAuthSchema(); return authConfigSchema;
} }
get authenticator(): Authenticator { get authenticator(): Authenticator {
@@ -182,6 +178,32 @@ export class AppAuth extends Module<AppAuthSchema> {
return created; return created;
} }
async changePassword(userId: PrimaryFieldType, newPassword: string) {
const users_entity = this.config.entity_name as "users";
const { data: user } = await this.em.repository(users_entity).findId(userId);
if (!user) {
throw new Error("User not found");
} else if (user.strategy !== "password") {
throw new Error("User is not using password strategy");
}
const togglePw = (visible: boolean) => {
const field = this.em.entity(users_entity).field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
};
const pw = this.authenticator.strategy("password" as const) as PasswordStrategy;
togglePw(true);
await this.em.mutator(users_entity).updateOne(user.id, {
strategy_value: await pw.hash(newPassword),
});
togglePw(false);
return true;
}
override toJSON(secrets?: boolean): AppAuthSchema { override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) { if (!this.config.enabled) {
return this.configDefault; return this.configDefault;
+1 -1
View File
@@ -1,6 +1,6 @@
import { AppAuth } from "auth/AppAuth"; import { AppAuth } from "auth/AppAuth";
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator"; import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
import { $console } from "core/utils"; import { $console } from "bknd/utils";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { import {
InvalidConditionsException, InvalidConditionsException,
+130 -2
View File
@@ -1,11 +1,20 @@
import type { SafeUser } from "bknd"; import type { DB, SafeUser } from "bknd";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy"; import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { AppAuth } from "auth/AppAuth"; import type { AppAuth } from "auth/AppAuth";
import * as AuthPermissions from "auth/auth-permissions"; import * as AuthPermissions from "auth/auth-permissions";
import * as DataPermissions from "data/permissions"; import * as DataPermissions from "data/permissions";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller"; import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils"; import {
describeRoute,
jsc,
s,
parse,
InvalidSchemaError,
transformObject,
mcpTool,
} from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies";
export type AuthActionResponse = { export type AuthActionResponse = {
success: boolean; success: boolean;
@@ -118,6 +127,9 @@ export class AuthController extends Controller {
summary: "Get the current user", summary: "Get the current user",
tags: ["auth"], tags: ["auth"],
}), }),
mcpTool("auth_me", {
noErrorCodes: [403],
}),
auth(), auth(),
async (c) => { async (c) => {
const claims = c.get("auth")?.user; const claims = c.get("auth")?.user;
@@ -159,6 +171,7 @@ export class AuthController extends Controller {
summary: "Get the available authentication strategies", summary: "Get the available authentication strategies",
tags: ["auth"], tags: ["auth"],
}), }),
mcpTool("auth_strategies"),
jsc("query", s.object({ include_disabled: s.boolean().optional() })), jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => { async (c) => {
const { include_disabled } = c.req.valid("query"); const { include_disabled } = c.req.valid("query");
@@ -188,4 +201,119 @@ export class AuthController extends Controller {
return hono; return hono;
} }
override registerMcp(): void {
const { mcp } = this.auth.ctx;
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined = undefined;
if (params.id) {
const { data } = await this.userRepo.findId(params.id);
user = data;
} else if (params.email) {
const { data } = await this.userRepo.findOne({ email: params.email });
user = data;
}
if (!user) {
throw new Error("User not found");
}
return user;
};
mcp.tool(
// @todo: needs permission
"auth_user_create",
{
description: "Create a new user",
inputSchema: s.object({
email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
role: s
.string({
enum: Object.keys(this.auth.config.roles ?? {}),
})
.optional(),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createUser, c);
return c.json(await this.auth.createUser(params));
},
);
mcp.tool(
// @todo: needs permission
"auth_user_token",
{
description: "Get a user token",
inputSchema: s.object({
id: idType.optional(),
email: s.string({ format: "email" }).optional(),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.createToken, c);
const user = await getUser(params);
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
},
);
mcp.tool(
// @todo: needs permission
"auth_user_password_change",
{
description: "Change a user's password",
inputSchema: s.object({
id: idType.optional(),
email: s.string({ format: "email" }).optional(),
password: s.string({ minLength: 8 }),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.changePassword, c);
const user = await getUser(params);
if (!(await this.auth.changePassword(user.id, params.password))) {
throw new Error("Failed to change password");
}
return c.json({ changed: true });
},
);
mcp.tool(
// @todo: needs permission
"auth_user_password_test",
{
description: "Test a user's password",
inputSchema: s.object({
email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
}),
},
async (params, c) => {
await c.context.ctx().helper.throwUnlessGranted(AuthPermissions.testPassword, c);
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator);
const res = await controller.request(
new Request("https://localhost/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: params.email,
password: params.password,
}),
}),
);
return c.json({ valid: res.ok });
},
);
}
} }
+3
View File
@@ -2,3 +2,6 @@ import { Permission } from "core/security/Permission";
export const createUser = new Permission("auth.user.create"); export const createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update"); //export const updateUser = new Permission("auth.user.update");
export const testPassword = new Permission("auth.user.password.test");
export const changePassword = new Permission("auth.user.password.change");
export const createToken = new Permission("auth.user.token.create");
+58 -50
View File
@@ -1,26 +1,41 @@
import { Registry, type Constructor } from "core/registry/Registry";
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { objectTransform, s } from "bknd/utils"; import { objectTransform, s } from "bknd/utils";
import type { ProvidedOAuthConfig } from "./authenticate/strategies/oauth/OAuthStrategy"; import { $object, $record } from "modules/mcp";
import type { OAuthConfigCustom } from "./authenticate/strategies/oauth/CustomOAuthStrategy";
import { PasswordStrategy } from "./authenticate/strategies/PasswordStrategy";
import { OAuthStrategy } from "./authenticate/strategies/oauth/OAuthStrategy";
import { CustomOAuthStrategy } from "./authenticate/strategies/oauth/CustomOAuthStrategy";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
export const AuthStrategyRegistry = new Registry<{ export const Strategies = {
cls: Constructor<AuthStrategy>; password: {
schema: s.Schema; cls: PasswordStrategy,
}>((cls: Constructor<AuthStrategy>) => ({ schema: PasswordStrategy.prototype.getSchema(),
cls, },
schema: cls.prototype.getSchema() as s.Schema, oauth: {
})) cls: OAuthStrategy,
.register("password", PasswordStrategy) schema: OAuthStrategy.prototype.getSchema(),
.register("oauth", OAuthStrategy) },
.register("custom_oauth", CustomOAuthStrategy); custom_oauth: {
cls: CustomOAuthStrategy,
schema: CustomOAuthStrategy.prototype.getSchema(),
},
} as const;
export type AppAuthOAuthStrategy = ProvidedOAuthConfig; export const STRATEGIES = Strategies;
export type AppAuthCustomOAuthStrategy = OAuthConfigCustom; const strategiesSchemaObject = objectTransform(STRATEGIES, (strategy, name) => {
return s.strictObject(
{
enabled: s.boolean({ default: true }).optional(),
type: s.literal(name),
config: strategy.schema,
},
{
title: name,
},
);
});
const strategiesSchema = s.anyOf(Object.values(strategiesSchemaObject));
export type AppAuthStrategies = s.Static<typeof strategiesSchema>;
export type AppAuthOAuthStrategy = s.Static<typeof STRATEGIES.oauth.schema>;
export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth.schema>;
const guardConfigSchema = s.object({ const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(), enabled: s.boolean({ default: false }).optional(),
@@ -31,30 +46,19 @@ export const guardRoleSchema = s.strictObject({
implicit_allow: s.boolean().optional(), implicit_allow: s.boolean().optional(),
}); });
export function buildAuthSchema() { export const authConfigSchema = $object(
const strategiesSchemaObject = objectTransform(AuthStrategyRegistry.all(), (strategy, name) => { "config_auth",
return s.strictObject( {
enabled: s.boolean({ default: false }),
basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(),
jwt: jwtConfig,
cookie: cookieConfig,
strategies: $record(
"config_auth_strategies",
strategiesSchema,
{ {
enabled: s.boolean({ default: true }).optional(),
type: s.literal(name),
config: strategy.schema,
},
{
title: name,
},
);
});
const strategiesSchema = s.anyOf(Object.values(strategiesSchemaObject));
return s.strictObject(
{
enabled: s.boolean({ default: false }),
basepath: s.string({ default: "/api/auth" }),
entity_name: s.string({ default: "users" }),
allow_register: s.boolean({ default: true }).optional(),
jwt: jwtConfig,
cookie: cookieConfig,
strategies: s.record(strategiesSchema, {
title: "Strategies", title: "Strategies",
default: { default: {
password: { password: {
@@ -65,15 +69,19 @@ export function buildAuthSchema() {
}, },
}, },
}, },
},
s.strictObject({
type: s.string(),
enabled: s.boolean({ default: true }).optional(),
config: s.object({}),
}), }),
guard: guardConfigSchema.optional(), ),
roles: s.record(guardRoleSchema, { default: {} }).optional(), guard: guardConfigSchema.optional(),
}, roles: $record("config_auth_roles", guardRoleSchema, { default: {} }).optional(),
{ title: "Authentication" }, },
); { title: "Authentication" },
} );
export const authConfigSchema = buildAuthSchema();
export type AppAuthStrategies = s.Static<(typeof authConfigSchema)["properties"]["strategies"]>;
export type AppAuthJWTConfig = s.Static<typeof jwtConfig>; export type AppAuthJWTConfig = s.Static<typeof jwtConfig>;
export type AppAuthSchema = s.Static<typeof authConfigSchema>; export type AppAuthSchema = s.Static<typeof authConfigSchema>;
+37 -24
View File
@@ -9,6 +9,7 @@ import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es"; import { pick } from "lodash-es";
import { InvalidConditionsException } from "auth/errors"; import { InvalidConditionsException } from "auth/errors";
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils"; import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
import { $object } from "modules/mcp";
import type { AuthStrategy } from "./strategies/Strategy"; import type { AuthStrategy } from "./strategies/Strategy";
type Input = any; // workaround type Input = any; // workaround
@@ -42,7 +43,7 @@ export interface UserPool {
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
export const cookieConfig = s export const cookieConfig = s
.object({ .strictObject({
path: s.string({ default: "/" }), path: s.string({ default: "/" }),
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }), sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
secure: s.boolean({ default: true }), secure: s.boolean({ default: true }),
@@ -53,27 +54,24 @@ export const cookieConfig = s
pathSuccess: s.string({ default: "/" }), pathSuccess: s.string({ default: "/" }),
pathLoggedOut: s.string({ default: "/" }), pathLoggedOut: s.string({ default: "/" }),
}) })
.partial() .partial();
.strict();
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably? // @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
// see auth.integration test for further details // see auth.integration test for further details
export const jwtConfig = s export const jwtConfig = s.strictObject(
.object( {
{ secret: secret({ default: "" }),
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
secret: secret({ default: "" }), expires: s.number().optional(), // seconds
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(), issuer: s.string().optional(),
expires: s.number().optional(), // seconds fields: s.array(s.string(), { default: ["id", "email", "role"] }),
issuer: s.string().optional(), },
fields: s.array(s.string(), { default: ["id", "email", "role"] }), {
}, default: {},
{ },
default: {}, );
},
)
.strict();
export const authenticatorConfig = s.object({ export const authenticatorConfig = s.object({
jwt: jwtConfig, jwt: jwtConfig,
cookie: cookieConfig, cookie: cookieConfig,
@@ -378,13 +376,28 @@ export class Authenticator<
} }
// @todo: don't extract user from token, but from the database or cache // @todo: don't extract user from token, but from the database or cache
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> { async resolveAuthFromRequest(c: Context | Request | Headers): Promise<SafeUser | undefined> {
let token: string | undefined; let headers: Headers;
if (c.req.raw.headers.has("Authorization")) { let is_context = false;
const bearerHeader = String(c.req.header("Authorization")); if (c instanceof Headers) {
token = bearerHeader.replace("Bearer ", ""); headers = c;
} else if (c instanceof Request) {
headers = c.headers;
} else { } else {
token = await this.getAuthCookie(c); is_context = true;
try {
headers = c.req.raw.headers;
} catch (e) {
throw new Exception("Request/Headers/Context is required to resolve auth", 400);
}
}
let token: string | undefined;
if (headers.has("Authorization")) {
const bearerHeader = String(headers.get("Authorization"));
token = bearerHeader.replace("Bearer ", "");
} else if (is_context) {
token = await this.getAuthCookie(c as Context);
} }
if (token) { if (token) {
@@ -1,11 +1,10 @@
import type { User } from "bknd"; import type { User } from "bknd";
import type { Authenticator } from "auth/authenticate/Authenticator"; import type { Authenticator } from "auth/authenticate/Authenticator";
import { InvalidCredentialsException } from "auth/errors"; import { InvalidCredentialsException } from "auth/errors";
import { hash, $console } from "core/utils"; import { hash, $console, s, parse, jsc } from "bknd/utils";
import { Hono } from "hono"; import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs"; import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import { AuthStrategy } from "./Strategy"; import { AuthStrategy } from "./Strategy";
import { s, parse, jsc } from "bknd/utils";
const schema = s const schema = s
.object({ .object({
@@ -30,7 +30,7 @@ const oauthSchemaCustom = s.strictObject(
{ title: "Custom OAuth" }, { title: "Custom OAuth" },
); );
export type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>; type OAuthConfigCustom = s.Static<typeof oauthSchemaCustom>;
export type UserProfile = { export type UserProfile = {
sub: string; sub: string;
@@ -26,7 +26,7 @@ const schemaProvided = s.object(
}, },
{ title: "OAuth" }, { title: "OAuth" },
); );
export type ProvidedOAuthConfig = s.Static<typeof schemaProvided>; type ProvidedOAuthConfig = s.Static<typeof schemaProvided>;
export type CustomOAuthConfig = { export type CustomOAuthConfig = {
type: SupportedTypes; type: SupportedTypes;
+1 -1
View File
@@ -1,5 +1,5 @@
import { Exception } from "core/errors"; import { Exception } from "core/errors";
import { $console, objectTransform } from "core/utils"; import { $console, objectTransform } from "bknd/utils";
import { Permission } from "core/security/Permission"; import { Permission } from "core/security/Permission";
import type { Context } from "hono"; import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller"; import type { ServerEnv } from "modules/Controller";
+27 -7
View File
@@ -1,15 +1,35 @@
import { getDefaultConfig } from "modules/ModuleManager"; import { getDefaultConfig } from "modules/ModuleManager";
import type { CliCommand } from "../types"; import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions } from "cli/utils/options";
export const config: CliCommand = (program) => { export const config: CliCommand = (program) => {
program withConfigOptions(program.command("config"))
.command("config") .description("get app config")
.description("get default config")
.option("--pretty", "pretty print") .option("--pretty", "pretty print")
.action((options) => { .option("--default", "use default config")
const config = getDefaultConfig(); .option("--secrets", "include secrets in output")
.option("--out <file>", "output file")
.action(async (options) => {
let config: any = {};
// biome-ignore lint/suspicious/noConsoleLog: if (options.default) {
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config)); config = getDefaultConfig();
} else {
const app = await makeAppFromEnv(options);
config = app.toJSON(options.secrets);
}
config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config);
console.info("");
if (options.out) {
await writeFile(options.out, config);
console.info(`Config written to ${c.cyan(options.out)}`);
} else {
console.info(JSON.parse(config));
}
}); });
}; };
@@ -1,7 +1,6 @@
import * as $p from "@clack/prompts"; import * as $p from "@clack/prompts";
import { overrideJson, overridePackageJson } from "cli/commands/create/npm"; import { overrideJson } from "cli/commands/create/npm";
import { typewriter, wait } from "cli/utils/cli"; import { typewriter } from "cli/utils/cli";
import { uuid } from "core/utils";
import c from "picocolors"; import c from "picocolors";
import type { Template, TemplateSetupCtx } from "."; import type { Template, TemplateSetupCtx } from ".";
import { exec } from "cli/utils/sys"; import { exec } from "cli/utils/sys";
+2
View File
@@ -6,3 +6,5 @@ export { user } from "./user";
export { create } from "./create"; export { create } from "./create";
export { copyAssets } from "./copy-assets"; export { copyAssets } from "./copy-assets";
export { types } from "./types"; export { types } from "./types";
export { mcp } from "./mcp/mcp";
export { sync } from "./sync";
+82
View File
@@ -0,0 +1,82 @@
import type { CliCommand } from "cli/types";
import { makeAppFromEnv } from "../run";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { $console, stdioTransport } from "bknd/utils";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
export const mcp: CliCommand = (program) =>
withConfigOptions(program.command("mcp"))
.description("mcp server stdio transport")
.option(
"--token <token>",
"token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable",
)
.option("--verbose", "verbose output")
.option("--log-level <level>", "log level")
.option("--force", "force enable mcp")
.action(action);
async function action(
options: WithConfigOptions<{
verbose?: boolean;
token?: string;
logLevel?: string;
force?: boolean;
}>,
) {
const verbose = !!options.verbose;
const __oldConsole = { ...console };
// disable console
if (!verbose) {
$console.disable();
Object.entries(console).forEach(([key]) => {
console[key] = () => null;
});
}
const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node",
});
if (!app.modules.get("server").config.mcp.enabled && !options.force) {
$console.enable();
Object.assign(console, __oldConsole);
console.error("MCP is not enabled in the config, use --force to enable it");
process.exit(1);
}
const token = options.token || process.env.BEARER_TOKEN;
const server = getSystemMcp(app);
if (verbose) {
console.info(
`\n⚙️ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`,
);
console.info(
`📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`,
);
console.info("\nMCP server is running on STDIO transport");
}
if (options.logLevel) {
server.setLogLevel(options.logLevel as any);
}
const stdout = process.stdout;
const stdin = process.stdin;
const stderr = process.stderr;
{
using transport = stdioTransport(server, {
stdin,
stdout,
stderr,
raw: new Request("https://localhost", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}),
});
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import path from "node:path"; import path from "node:path";
import { $console } from "core/utils"; import { $console } from "bknd/utils";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import open from "open"; import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys"; import { fileExists, getRelativeDistPath } from "../../utils/sys";
+5 -11
View File
@@ -17,6 +17,7 @@ import {
} from "./platform"; } from "./platform";
import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { createRuntimeApp, makeConfig } from "bknd/adapter";
import { colorizeConsole, isBun } from "bknd/utils"; import { colorizeConsole, isBun } from "bknd/utils";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
const env_files = [".env", ".dev.vars"]; const env_files = [".env", ".dev.vars"];
dotenv.config({ dotenv.config({
@@ -25,8 +26,7 @@ dotenv.config({
const is_bun = isBun(); const is_bun = isBun();
export const run: CliCommand = (program) => { export const run: CliCommand = (program) => {
program withConfigOptions(program.command("run"))
.command("run")
.description("run an instance") .description("run an instance")
.addOption( .addOption(
new Option("-p, --port <port>", "port to run on") new Option("-p, --port <port>", "port to run on")
@@ -41,12 +41,6 @@ export const run: CliCommand = (program) => {
"db-token", "db-token",
]), ]),
) )
.addOption(new Option("-c, --config <config>", "config file"))
.addOption(
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
"config",
),
)
.addOption( .addOption(
new Option("--server <server>", "server type") new Option("--server <server>", "server type")
.choices(PLATFORMS) .choices(PLATFORMS)
@@ -77,21 +71,21 @@ async function makeApp(config: MakeAppConfig) {
} }
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
const config = makeConfig(_config, process.env); const config = await makeConfig(_config, process.env);
return makeApp({ return makeApp({
...config, ...config,
server: { platform }, server: { platform },
}); });
} }
type RunOptions = { type RunOptions = WithConfigOptions<{
port: number; port: number;
memory?: boolean; memory?: boolean;
config?: string; config?: string;
dbUrl?: string; dbUrl?: string;
server: Platform; server: Platform;
open?: boolean; open?: boolean;
}; }>;
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) { export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
+45
View File
@@ -0,0 +1,45 @@
import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions } from "cli/utils/options";
export const sync: CliCommand = (program) => {
withConfigOptions(program.command("sync"))
.description("sync database")
.option("--dump", "dump operations to console instead of executing them")
.option("--drop", "include destructive DDL operations")
.option("--out <file>", "output file")
.option("--sql", "use sql output")
.action(async (options) => {
const app = await makeAppFromEnv(options);
const schema = app.em.schema();
const stmts = await schema.sync({ drop: options.drop });
console.info("");
if (stmts.length === 0) {
console.info(c.yellow("No changes to sync"));
process.exit(0);
}
// @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.dump) {
if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} else {
console.info(options.sql ? c.cyan(sql) : stmts);
}
process.exit(0);
}
await schema.sync({ force: true, drop: options.drop });
console.info(c.cyan(sql));
console.info(`${c.gray(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
});
};
+12 -11
View File
@@ -4,34 +4,35 @@ import { makeAppFromEnv } from "cli/commands/run";
import { EntityTypescript } from "data/entities/EntityTypescript"; import { EntityTypescript } from "data/entities/EntityTypescript";
import { writeFile } from "cli/utils/sys"; import { writeFile } from "cli/utils/sys";
import c from "picocolors"; import c from "picocolors";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
export const types: CliCommand = (program) => { export const types: CliCommand = (program) => {
program withConfigOptions(program.command("types"))
.command("types")
.description("generate types") .description("generate types")
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts")) .addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
.addOption(new Option("--no-write", "do not write to file").default(true)) .addOption(new Option("--dump", "dump types to console instead of writing to file"))
.action(action); .action(action);
}; };
async function action({ async function action({
outfile, outfile,
write, dump,
}: { ...options
}: WithConfigOptions<{
outfile: string; outfile: string;
write: boolean; dump: boolean;
}) { }>) {
const app = await makeAppFromEnv({ const app = await makeAppFromEnv({
server: "node", server: "node",
...options,
}); });
await app.build();
const et = new EntityTypescript(app.em); const et = new EntityTypescript(app.em);
if (write) { if (dump) {
console.info(et.toString());
} else {
await writeFile(outfile, et.toString()); await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`); console.info(`\nTypes written to ${c.cyan(outfile)}`);
} else {
console.info(et.toString());
} }
} }
+12 -27
View File
@@ -9,13 +9,12 @@ import type { PasswordStrategy } from "auth/authenticate/strategies";
import { makeAppFromEnv } from "cli/commands/run"; import { makeAppFromEnv } from "cli/commands/run";
import type { CliCommand } from "cli/types"; import type { CliCommand } from "cli/types";
import { Argument } from "commander"; import { Argument } from "commander";
import { $console } from "core/utils"; import { $console, isBun } from "bknd/utils";
import c from "picocolors"; import c from "picocolors";
import { isBun } from "core/utils"; import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
export const user: CliCommand = (program) => { export const user: CliCommand = (program) => {
program withConfigOptions(program.command("user"))
.command("user")
.description("create/update users, or generate a token (auth)") .description("create/update users, or generate a token (auth)")
.addArgument( .addArgument(
new Argument("<action>", "action to perform").choices(["create", "update", "token"]), new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
@@ -23,8 +22,10 @@ export const user: CliCommand = (program) => {
.action(action); .action(action);
}; };
async function action(action: "create" | "update" | "token", options: any) { async function action(action: "create" | "update" | "token", options: WithConfigOptions) {
const app = await makeAppFromEnv({ const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node", server: "node",
}); });
@@ -85,9 +86,6 @@ async function create(app: App, options: any) {
async function update(app: App, options: any) { async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true); const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em;
const email = (await $text({ const email = (await $text({
message: "Which user? Enter email", message: "Which user? Enter email",
@@ -100,7 +98,10 @@ async function update(app: App, options: any) {
})) as string; })) as string;
if ($isCancel(email)) process.exit(1); if ($isCancel(email)) process.exit(1);
const { data: user } = await em.repository(users_entity).findOne({ email }); const { data: user } = await app.modules
.ctx()
.em.repository(config.entity_name as "users")
.findOne({ email });
if (!user) { if (!user) {
$log.error("User not found"); $log.error("User not found");
process.exit(1); process.exit(1);
@@ -118,26 +119,10 @@ async function update(app: App, options: any) {
}); });
if ($isCancel(password)) process.exit(1); if ($isCancel(password)) process.exit(1);
try { if (await app.module.auth.changePassword(user.id, password)) {
function togglePw(visible: boolean) {
const field = em.entity(users_entity).field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
}
togglePw(true);
await app.modules
.ctx()
.em.mutator(users_entity)
.updateOne(user.id, {
strategy_value: await strategy.hash(password as string),
});
togglePw(false);
$log.success(`Updated user: ${c.cyan(user.email)}`); $log.success(`Updated user: ${c.cyan(user.email)}`);
} catch (e) { } else {
$log.error("Error updating user"); $log.error("Error updating user");
$console.error(e);
} }
} }
+16
View File
@@ -0,0 +1,16 @@
import { type Command, Option } from "commander";
export function withConfigOptions(program: Command) {
return program
.addOption(new Option("-c, --config <config>", "config file"))
.addOption(
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
"config",
),
);
}
export type WithConfigOptions<CustomOptions = {}> = {
config?: string;
dbUrl?: string;
} & CustomOptions;
+2 -2
View File
@@ -1,4 +1,4 @@
import { $console } from "core/utils"; import { $console } from "bknd/utils";
import { execSync, exec as nodeExec } from "node:child_process"; import { execSync, exec as nodeExec } from "node:child_process";
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises"; import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
@@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") {
return JSON.parse(pkg).version ?? "preview"; return JSON.parse(pkg).version ?? "preview";
} }
} catch (e) { } catch (e) {
console.error("Failed to resolve version"); //console.error("Failed to resolve version");
} }
return "unknown"; return "unknown";
+1 -1
View File
@@ -1,4 +1,4 @@
import { mergeObject, type RecursivePartial } from "core/utils"; import { mergeObject, type RecursivePartial } from "bknd/utils";
import type { IEmailDriver } from "./index"; import type { IEmailDriver } from "./index";
export type MailchannelsEmailOptions = { export type MailchannelsEmailOptions = {
+1 -1
View File
@@ -1,6 +1,6 @@
import { type Event, type EventClass, InvalidEventReturn } from "./Event"; import { type Event, type EventClass, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
import { $console } from "core/utils"; import { $console } from "bknd/utils";
export type RegisterListenerConfig = export type RegisterListenerConfig =
| ListenerMode | ListenerMode
+2 -3
View File
@@ -27,9 +27,9 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
) { ) {
this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any); this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any);
this._value = deepFreeze( this._value = deepFreeze(
parse(_schema, structuredClone(initial ?? {}), { parse(_schema, initial ?? {}, {
withDefaults: true, withDefaults: true,
withExtendedDefaults: true, //withExtendedDefaults: true,
forceParse: this.isForceParse(), forceParse: this.isForceParse(),
skipMark: this.isForceParse(), skipMark: this.isForceParse(),
}), }),
@@ -177,7 +177,6 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
this.throwIfRestricted(partial); this.throwIfRestricted(partial);
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore // @ts-ignore
const config = set(current, path, value); const config = set(current, path, value);
+1 -1
View File
@@ -1,5 +1,5 @@
export type Constructor<T> = new (...args: any[]) => T; export type Constructor<T> = new (...args: any[]) => T;
export type ClassThatImplements<T> = Constructor<T> & { prototype: T };
export type RegisterFn<Item> = (unknown: any) => Item; export type RegisterFn<Item> = (unknown: any) => Item;
export class Registry< export class Registry<
+30 -5
View File
@@ -1,12 +1,37 @@
import { createApp as createAppInternal, type CreateAppConfig } from "App"; import { Connection, createApp as createAppInternal, type CreateAppConfig } from "bknd";
import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection"; import { bunSqlite } from "bknd/adapter/bun";
import { Connection } from "data/connection/Connection"; import type { McpServer } from "bknd/utils";
export { App } from "App"; export { App } from "bknd";
export function createApp({ connection, ...config }: CreateAppConfig = {}) { export function createApp({ connection, ...config }: CreateAppConfig = {}) {
return createAppInternal({ return createAppInternal({
...config, ...config,
connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any), connection: Connection.isConnection(connection)
? connection
: (bunSqlite(connection as any) as any),
}); });
} }
export function createMcpToolCaller() {
return async (server: McpServer, name: string, args: any, raw?: any) => {
const res = await server.handle(
{
jsonrpc: "2.0",
method: "tools/call",
params: {
name,
arguments: args,
},
},
raw,
);
if ((res.result as any)?.isError) {
console.dir(res.result, { depth: null });
throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error");
}
return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null");
};
}
+16
View File
@@ -76,6 +76,7 @@ declare global {
| { | {
level: TConsoleSeverity; level: TConsoleSeverity;
id?: string; id?: string;
enabled?: boolean;
} }
| undefined; | undefined;
} }
@@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity;
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation> // biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
const config = (globalThis.__consoleConfig ??= { const config = (globalThis.__consoleConfig ??= {
level: defaultLevel, level: defaultLevel,
enabled: true,
//id: crypto.randomUUID(), // for debugging //id: crypto.randomUUID(), // for debugging
}); });
@@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, {
switch (prop) { switch (prop) {
case "original": case "original":
return console; return console;
case "disable":
return () => {
config.enabled = false;
};
case "enable":
return () => {
config.enabled = true;
};
case "setLevel": case "setLevel":
return (l: TConsoleSeverity) => { return (l: TConsoleSeverity) => {
config.level = l; config.level = l;
@@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, {
}; };
} }
if (!config.enabled) {
return () => null;
}
const current = keys.indexOf(config.level); const current = keys.indexOf(config.level);
const requested = keys.indexOf(prop as string); const requested = keys.indexOf(prop as string);
@@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, {
} & { } & {
setLevel: (l: TConsoleSeverity) => void; setLevel: (l: TConsoleSeverity) => void;
resetLevel: () => void; resetLevel: () => void;
disable: () => void;
enable: () => void;
}; };
export function colorizeConsole(con: typeof console) { export function colorizeConsole(con: typeof console) {
+2 -2
View File
@@ -1,7 +1,7 @@
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny"; import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
import { randomString } from "core/utils/strings"; import { randomString } from "./strings";
import type { Context } from "hono"; import type { Context } from "hono";
import { invariant } from "core/utils/runtime"; import { invariant } from "./runtime";
import { $console } from "./console"; import { $console } from "./console";
export function getContentName(request: Request): string | undefined; export function getContentName(request: Request): string | undefined;
+2 -15
View File
@@ -13,18 +13,5 @@ export * from "./uuid";
export * from "./test"; export * from "./test";
export * from "./runtime"; export * from "./runtime";
export * from "./numbers"; export * from "./numbers";
export { export * from "./schema";
s, export { DebugLogger } from "./DebugLogger";
stripMark,
mark,
stringIdentifier,
SecretSchema,
secret,
parse,
jsc,
describeRoute,
schemaToSpec,
openAPISpecs,
type ParseOptions,
InvalidSchemaError,
} from "./schema";
+2 -2
View File
@@ -14,10 +14,10 @@ export function ensureInt(value?: string | number | null | undefined): number {
export const formatNumber = { export const formatNumber = {
fileSize: (bytes: number, decimals = 2): string => { fileSize: (bytes: number, decimals = 2): string => {
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 B";
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i]; return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
}, },
+38
View File
@@ -26,6 +26,20 @@ export function omitKeys<T extends object, K extends keyof T>(
return result; return result;
} }
export function pickKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[],
): Pick<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const result = {} as Pick<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (keys.has(key as K)) {
(result as any)[key] = value;
}
}
return result;
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T { export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
try { try {
@@ -189,6 +203,30 @@ export function objectDepth(object: object): number {
return level; return level;
} }
export function limitObjectDepth<T>(obj: T, maxDepth: number): T {
function _limit(current: any, depth: number): any {
if (isPlainObject(current)) {
if (depth > maxDepth) {
return undefined;
}
const result: any = {};
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
result[key] = _limit(current[key], depth + 1);
}
}
return result;
}
if (Array.isArray(current)) {
// Arrays themselves are not limited, but their object elements are
return current.map((item) => _limit(item, depth));
}
// Primitives are always returned, regardless of depth
return current;
}
return _limit(obj, 1);
}
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj { export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
if (!obj) return obj; if (!obj) return obj;
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
+15 -1
View File
@@ -1,7 +1,21 @@
import * as s from "jsonv-ts"; import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono"; export { validator as jsc, type Options } from "jsonv-ts/hono";
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono"; export { describeRoute, schemaToSpec, openAPISpecs, info } from "jsonv-ts/hono";
export {
mcp,
McpServer,
Resource,
Tool,
mcpTool,
mcpResource,
getMcpServer,
stdioTransport,
McpClient,
type McpClientConfig,
type ToolAnnotation,
type ToolHandlerCtx,
} from "jsonv-ts/mcp";
export { secret, SecretSchema } from "./secret"; export { secret, SecretSchema } from "./secret";
+4 -3
View File
@@ -1,6 +1,7 @@
import { StringSchema, type IStringOptions } from "jsonv-ts"; import type { s } from "bknd/utils";
import { StringSchema } from "jsonv-ts";
export class SecretSchema<O extends IStringOptions> extends StringSchema<O> {} export class SecretSchema<O extends s.IStringOptions> extends StringSchema<O> {}
export const secret = <O extends IStringOptions>(o?: O): SecretSchema<O> & O => export const secret = <O extends s.IStringOptions>(o?: O): SecretSchema<O> & O =>
new SecretSchema(o) as any; new SecretSchema(o) as any;
+4 -6
View File
@@ -1,5 +1,4 @@
import { transformObject } from "core/utils"; import { transformObject } from "bknd/utils";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { DataController } from "./api/DataController"; import { DataController } from "./api/DataController";
import { type AppDataConfig, dataConfigSchema } from "./data-schema"; import { type AppDataConfig, dataConfigSchema } from "./data-schema";
@@ -49,10 +48,9 @@ export class AppData extends Module<AppDataConfig> {
this.ctx.em.addIndex(index); this.ctx.em.addIndex(index);
} }
this.ctx.server.route( const dataController = new DataController(this.ctx, this.config);
this.basepath, dataController.registerMcp();
new DataController(this.ctx, this.config).getController(), this.ctx.server.route(this.basepath, dataController.getController());
);
this.ctx.guard.registerPermissions(Object.values(DataPermissions)); this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.setBuilt(); this.setBuilt();
+78 -17
View File
@@ -1,7 +1,6 @@
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 } from "bknd/utils"; import { jsc, s, describeRoute, schemaToSpec, omitKeys, pickKeys, mcpTool } 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 +36,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,16 +43,18 @@ 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
hono.get( hono.get(
"/sync", "/sync",
permission(DataPermissions.databaseSync), permission(DataPermissions.databaseSync),
mcpTool("data_sync", {
annotations: {
destructiveHint: true,
},
}),
describeRoute({ describeRoute({
summary: "Sync database schema", summary: "Sync database schema",
tags: ["data"], tags: ["data"],
@@ -77,9 +70,7 @@ export class DataController extends Controller {
), ),
async (c) => { async (c) => {
const { force, drop } = c.req.valid("query"); const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect(); const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({ const changes = await this.em.schema().sync({
force, force,
drop, drop,
@@ -165,6 +156,7 @@ export class DataController extends Controller {
summary: "Retrieve entity info", summary: "Retrieve entity info",
tags: ["data"], tags: ["data"],
}), }),
mcpTool("data_entity_info"),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
async (c) => { async (c) => {
const { entity } = c.req.param(); const { entity } = c.req.param();
@@ -201,7 +193,9 @@ export class DataController extends Controller {
const entitiesEnum = this.getEntitiesEnum(this.em); const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity // @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string }); const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], {
coerce: (v) => v as number | string,
});
/** /**
* Function endpoints * Function endpoints
@@ -214,6 +208,7 @@ export class DataController extends Controller {
summary: "Count entities", summary: "Count entities",
tags: ["data"], tags: ["data"],
}), }),
mcpTool("data_entity_fn_count"),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where), jsc("json", repoQuery.properties.where),
async (c) => { async (c) => {
@@ -236,6 +231,7 @@ export class DataController extends Controller {
summary: "Check if entity exists", summary: "Check if entity exists",
tags: ["data"], tags: ["data"],
}), }),
mcpTool("data_entity_fn_exists"),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where), jsc("json", repoQuery.properties.where),
async (c) => { async (c) => {
@@ -268,6 +264,9 @@ export class DataController extends Controller {
(p) => pick.includes(p.name), (p) => pick.includes(p.name),
) as any), ) as any),
]; ];
const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => {
return s.object(pickKeys(saveRepoQuery.properties, pick as any));
};
hono.get( hono.get(
"/:entity", "/:entity",
@@ -300,6 +299,13 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
mcpTool("data_entity_read_one", {
inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }),
query: saveRepoQuerySchema(["offset", "sort", "select"]),
},
noErrorCodes: [404],
}),
jsc( jsc(
"param", "param",
s.object({ s.object({
@@ -375,6 +381,12 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityRead), permission(DataPermissions.entityRead),
mcpTool("data_entity_read_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: fnQuery,
},
}),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery, { skipOpenAPI: true }), jsc("json", repoQuery, { skipOpenAPI: true }),
async (c) => { async (c) => {
@@ -400,6 +412,7 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityCreate), permission(DataPermissions.entityCreate),
mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])), jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
async (c) => { async (c) => {
@@ -427,6 +440,15 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
mcpTool("data_entity_update_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: s.object({
update: s.object({}),
where: s.object({}),
}),
},
}),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc( jsc(
"json", "json",
@@ -458,6 +480,7 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityUpdate), permission(DataPermissions.entityUpdate),
mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})), jsc("json", s.object({})),
async (c) => { async (c) => {
@@ -480,6 +503,7 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })), jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => { async (c) => {
const { entity, id } = c.req.valid("param"); const { entity, id } = c.req.valid("param");
@@ -500,6 +524,12 @@ export class DataController extends Controller {
tags: ["data"], tags: ["data"],
}), }),
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
mcpTool("data_entity_delete_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: s.object({}),
},
}),
jsc("param", s.object({ entity: entitiesEnum })), jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where), jsc("json", repoQuery.properties.where),
async (c) => { async (c) => {
@@ -516,4 +546,35 @@ export class DataController extends Controller {
return hono; return hono;
} }
override registerMcp() {
this.ctx.mcp
.resource(
"data_entities",
"bknd://data/entities",
(c) => c.json(c.context.ctx().em.toJSON().entities),
{
title: "Entities",
description: "Retrieve all entities",
},
)
.resource(
"data_relations",
"bknd://data/relations",
(c) => c.json(c.context.ctx().em.toJSON().relations),
{
title: "Relations",
description: "Retrieve all relations",
},
)
.resource(
"data_indices",
"bknd://data/indices",
(c) => c.json(c.context.ctx().em.toJSON().indices),
{
title: "Indices",
description: "Retrieve all indices",
},
);
}
} }
@@ -1,6 +1,6 @@
import type { TestRunner } from "core/test"; import type { TestRunner } from "core/test";
import { Connection, type FieldSpec } from "./Connection"; import { Connection, type FieldSpec } from "./Connection";
import { getPath } from "core/utils"; import { getPath } from "bknd/utils";
import * as proto from "data/prototype"; import * as proto from "data/prototype";
import { createApp } from "App"; import { createApp } from "App";
import type { MaybePromise } from "core/types"; import type { MaybePromise } from "core/types";
+1
View File
@@ -9,6 +9,7 @@ export {
type ConnQueryResults, type ConnQueryResults,
customIntrospector, customIntrospector,
} from "./Connection"; } from "./Connection";
export { DummyConnection } from "./DummyConnection";
// sqlite // sqlite
export { SqliteConnection } from "./sqlite/SqliteConnection"; export { SqliteConnection } from "./sqlite/SqliteConnection";
+41 -11
View File
@@ -1,16 +1,18 @@
import { objectTransform } from "core/utils"; import { objectTransform } from "bknd/utils";
import { MediaField, mediaFieldConfigSchema } from "../media/MediaField"; import { MediaField, mediaFieldConfigSchema } from "../media/MediaField";
import { FieldClassMap } from "data/fields"; import { FieldClassMap } from "data/fields";
import { RelationClassMap, RelationFieldClassMap } from "data/relations"; import { RelationClassMap, RelationFieldClassMap } from "data/relations";
import { entityConfigSchema, entityTypes } from "data/entities"; import { entityConfigSchema, entityTypes } from "data/entities";
import { primaryFieldTypes } from "./fields"; import { primaryFieldTypes, baseFieldConfigSchema } from "./fields";
import { s } from "bknd/utils"; import { s } from "bknd/utils";
import { $object, $record } from "modules/mcp";
export const FIELDS = { export const FIELDS = {
...FieldClassMap, ...FieldClassMap,
...RelationFieldClassMap, ...RelationFieldClassMap,
media: { schema: mediaFieldConfigSchema, field: MediaField }, media: { schema: mediaFieldConfigSchema, field: MediaField },
}; };
export const FIELD_TYPES = Object.keys(FIELDS);
export type FieldType = keyof typeof FIELDS; export type FieldType = keyof typeof FIELDS;
export const RELATIONS = RelationClassMap; export const RELATIONS = RelationClassMap;
@@ -28,17 +30,30 @@ export const fieldsSchemaObject = objectTransform(FIELDS, (field, name) => {
); );
}); });
export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject)); export const fieldsSchema = s.anyOf(Object.values(fieldsSchemaObject));
export const entityFields = s.record(fieldsSchema); export const entityFields = s.record(fieldsSchema, { default: {} });
export type TAppDataField = s.Static<typeof fieldsSchema>; export type TAppDataField = s.Static<typeof fieldsSchema>;
export type TAppDataEntityFields = s.Static<typeof entityFields>; export type TAppDataEntityFields = s.Static<typeof entityFields>;
export const entitiesSchema = s.strictObject({ export const entitiesSchema = s.strictObject({
name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI) name: s.string().optional(), // @todo: verify, old schema wasn't strict (req in UI)
type: s.string({ enum: entityTypes, default: "regular" }), type: s.string({ enum: entityTypes, default: "regular" }).optional(),
config: entityConfigSchema, config: entityConfigSchema.optional(),
fields: entityFields, fields: entityFields.optional(),
}); });
export type TAppDataEntity = s.Static<typeof entitiesSchema>; export type TAppDataEntity = s.Static<typeof entitiesSchema>;
export const simpleEntitiesSchema = s.strictObject({
type: s.string({ enum: entityTypes, default: "regular" }).optional(),
config: entityConfigSchema.optional(),
fields: s
.record(
s.object({
type: s.anyOf([s.string({ enum: FIELD_TYPES }), s.string()]),
config: baseFieldConfigSchema.optional(),
}),
{ default: {} },
)
.optional(),
});
export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => { export const relationsSchema = Object.entries(RelationClassMap).map(([name, relationClass]) => {
return s.strictObject( return s.strictObject(
@@ -61,12 +76,27 @@ export const indicesSchema = s.strictObject({
unique: s.boolean({ default: false }).optional(), unique: s.boolean({ default: false }).optional(),
}); });
export const dataConfigSchema = s.strictObject({ export const dataConfigSchema = $object("config_data", {
basepath: s.string({ default: "/api/data" }).optional(), basepath: s.string({ default: "/api/data" }).optional(),
default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(), default_primary_format: s.string({ enum: primaryFieldTypes, default: "integer" }).optional(),
entities: s.record(entitiesSchema, { default: {} }).optional(), entities: $record("config_data_entities", entitiesSchema, { default: {} }).optional(),
relations: s.record(s.anyOf(relationsSchema), { default: {} }).optional(), relations: $record(
indices: s.record(indicesSchema, { default: {} }).optional(), "config_data_relations",
}); s.anyOf(relationsSchema),
{
default: {},
},
s.strictObject({
type: s.string({ enum: Object.keys(RelationClassMap) }),
source: s.string(),
target: s.string(),
config: s.object({}).optional(),
}),
).optional(),
indices: $record("config_data_indices", indicesSchema, {
default: {},
mcp: { update: false },
}).optional(),
}).strict();
export type AppDataConfig = s.Static<typeof dataConfigSchema>; export type AppDataConfig = s.Static<typeof dataConfigSchema>;
+11 -8
View File
@@ -10,14 +10,17 @@ import {
// @todo: entity must be migrated to typebox // @todo: entity must be migrated to typebox
export const entityConfigSchema = s export const entityConfigSchema = s
.strictObject({ .strictObject(
name: s.string(), {
name_singular: s.string(), name: s.string(),
description: s.string(), name_singular: s.string(),
sort_field: s.string({ default: config.data.default_primary_field }), description: s.string(),
sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }), sort_field: s.string({ default: config.data.default_primary_field }),
primary_format: s.string({ enum: primaryFieldTypes }), sort_dir: s.string({ enum: ["asc", "desc"], default: "asc" }),
}) primary_format: s.string({ enum: primaryFieldTypes }),
},
{ default: {} },
)
.partial(); .partial();
export type EntityConfig = s.Static<typeof entityConfigSchema>; export type EntityConfig = s.Static<typeof entityConfigSchema>;
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Entity, EntityManager, TEntityType } from "data/entities"; import type { Entity, EntityManager, TEntityType } from "data/entities";
import type { EntityRelation } from "data/relations"; import type { EntityRelation } from "data/relations";
import { autoFormatString } from "core/utils"; import { autoFormatString } from "bknd/utils";
import { usersFields } from "auth/auth-entities"; import { usersFields } from "auth/auth-entities";
import { mediaFields } from "media/media-entities"; import { mediaFields } from "media/media-entities";
@@ -170,7 +170,7 @@ export class EntityTypescript {
const strings: string[] = []; const strings: string[] = [];
const tables: Record<string, string> = {}; const tables: Record<string, string> = {};
const imports: Record<string, string[]> = { const imports: Record<string, string[]> = {
"bknd/core": ["DB"], bknd: ["DB"],
kysely: ["Insertable", "Selectable", "Updateable", "Generated"], kysely: ["Insertable", "Selectable", "Updateable", "Generated"],
}; };
@@ -207,7 +207,7 @@ export class EntityTypescript {
strings.push(tables_string); strings.push(tables_string);
// merge // merge
let merge = `declare module "bknd/core" {\n`; let merge = `declare module "bknd" {\n`;
for (const systemEntity of system_entities) { for (const systemEntity of system_entities) {
const system_fields = Object.keys(systemEntities[systemEntity.name]); const system_fields = Object.keys(systemEntities[systemEntity.name]);
const additional_fields = systemEntity.fields const additional_fields = systemEntity.fields
+1 -1
View File
@@ -1,5 +1,5 @@
import { isDebug } from "core/env"; import { isDebug } from "core/env";
import { pick } from "core/utils"; import { pick } from "bknd/utils";
import type { Connection } from "data/connection"; import type { Connection } from "data/connection";
import type { import type {
Compilable, Compilable,
@@ -1,4 +1,4 @@
import { $console } from "core/utils"; import { $console } from "bknd/utils";
import type { Entity, EntityData } from "../Entity"; import type { Entity, EntityData } from "../Entity";
import type { EntityManager } from "../EntityManager"; import type { EntityManager } from "../EntityManager";
import { Result, type ResultJSON, type ResultOptions } from "../Result"; import { Result, type ResultJSON, type ResultOptions } from "../Result";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { DB as DefaultDB, PrimaryFieldType } from "bknd"; import type { DB as DefaultDB, PrimaryFieldType } from "bknd";
import { $console } from "core/utils"; import { $console } from "bknd/utils";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
import { InvalidSearchParamsException } from "../../errors"; import { InvalidSearchParamsException } from "../../errors";
@@ -2,7 +2,7 @@ import type { Entity, EntityData } from "../Entity";
import type { EntityManager } from "../EntityManager"; import type { EntityManager } from "../EntityManager";
import { Result, type ResultJSON, type ResultOptions } from "../Result"; import { Result, type ResultJSON, type ResultOptions } from "../Result";
import type { Compilable, SelectQueryBuilder } from "kysely"; import type { Compilable, SelectQueryBuilder } from "kysely";
import { $console, ensureInt } from "core/utils"; import { $console, ensureInt } from "bknd/utils";
export type RepositoryResultOptions = ResultOptions & { export type RepositoryResultOptions = ResultOptions & {
silent?: boolean; silent?: boolean;
+1 -1
View File
@@ -1,4 +1,4 @@
import { isObject } from "core/utils"; import { isObject } from "bknd/utils";
import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { KyselyJsonFrom } from "data/relations/EntityRelation";
import type { RepoQuery } from "data/server/query"; import type { RepoQuery } from "data/server/query";
+1 -2
View File
@@ -1,8 +1,7 @@
import { omitKeys } from "core/utils"; import { omitKeys, s } from "bknd/utils";
import type { EntityManager } from "data/entities"; import type { EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../errors"; import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import { s } from "bknd/utils";
export const booleanFieldConfigSchema = s export const booleanFieldConfigSchema = s
.strictObject({ .strictObject({
+1 -3
View File
@@ -1,9 +1,7 @@
import { dayjs } from "core/utils"; import { dayjs, $console, s } from "bknd/utils";
import type { EntityManager } from "../entities"; import type { EntityManager } from "../entities";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import { $console } from "core/utils";
import type { TFieldTSType } from "data/entities/EntityTypescript"; import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils";
export const dateFieldConfigSchema = s export const dateFieldConfigSchema = s
.strictObject({ .strictObject({
+1 -1
View File
@@ -1,4 +1,4 @@
import { omitKeys } from "core/utils"; import { omitKeys } from "bknd/utils";
import type { EntityManager } from "data/entities"; import type { EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../errors"; import { TransformPersistFailedException } from "../errors";
import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field"; import { baseFieldConfigSchema, Field, type TActionContext, type TRenderContext } from "./Field";
+1 -1
View File
@@ -1,4 +1,4 @@
import { omitKeys } from "core/utils"; import { omitKeys } from "bknd/utils";
import type { EntityManager } from "data/entities"; import type { EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../errors"; import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
+1 -1
View File
@@ -1,5 +1,5 @@
import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema"; import { type Schema as JsonSchema, Validator } from "@cfworker/json-schema";
import { objectToJsLiteral } from "core/utils"; import { objectToJsLiteral } from "bknd/utils";
import type { EntityManager } from "data/entities"; import type { EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../errors"; import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
+1 -2
View File
@@ -2,8 +2,7 @@ import type { EntityManager } from "data/entities";
import { TransformPersistFailedException } from "../errors"; import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field"; import { Field, type TActionContext, type TRenderContext, baseFieldConfigSchema } from "./Field";
import type { TFieldTSType } from "data/entities/EntityTypescript"; import type { TFieldTSType } from "data/entities/EntityTypescript";
import { s } from "bknd/utils"; import { s, omitKeys } from "bknd/utils";
import { omitKeys } from "core/utils";
export const numberFieldConfigSchema = s export const numberFieldConfigSchema = s
.strictObject({ .strictObject({
+1 -2
View File
@@ -1,8 +1,7 @@
import type { EntityManager } from "data/entities"; import type { EntityManager } from "data/entities";
import { omitKeys } from "core/utils"; import { omitKeys, s } from "bknd/utils";
import { TransformPersistFailedException } from "../errors"; import { TransformPersistFailedException } from "../errors";
import { Field, type TActionContext, baseFieldConfigSchema } from "./Field"; import { Field, type TActionContext, baseFieldConfigSchema } from "./Field";
import { s } from "bknd/utils";
export const textFieldConfigSchema = s export const textFieldConfigSchema = s
.strictObject({ .strictObject({
+15
View File
@@ -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) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { transformObject } from "core/utils"; import { transformObject } from "bknd/utils";
import { Entity } from "data/entities"; import { Entity } from "data/entities";
import type { Field } from "data/fields"; import type { Field } from "data/fields";
import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema"; import { FIELDS, RELATIONS, type TAppDataEntity, type TAppDataRelation } from "data/data-schema";
-3
View File
@@ -26,9 +26,6 @@ describe("server/query", () => {
expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] }); expect(parse({ select: "id,title" })).toEqual({ select: ["id", "title"] });
expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] }); expect(parse({ select: "id,title,desc" })).toEqual({ select: ["id", "title", "desc"] });
expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] }); expect(parse({ select: ["id", "title"] })).toEqual({ select: ["id", "title"] });
expect(() => parse({ select: "not allowed" })).toThrow();
expect(() => parse({ select: "id," })).toThrow();
}); });
test("join", () => { test("join", () => {
+7 -7
View File
@@ -1,13 +1,11 @@
import { s } from "bknd/utils"; import { s, isObject, $console } from "bknd/utils";
import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder"; import { WhereBuilder, type WhereQuery } from "data/entities/query/WhereBuilder";
import { isObject, $console } from "core/utils";
import type { anyOf, CoercionOptions, Schema } from "jsonv-ts";
// ------- // -------
// helpers // helpers
const stringIdentifier = s.string({ const stringIdentifier = s.string({
// allow "id", "id,title" but not "id," or "not allowed" // allow "id", "id,title" but not "id," or "not allowed"
pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$", //pattern: "^(?:[a-zA-Z_$][\\w$]*)(?:,[a-zA-Z_$][\\w$]*)*$",
}); });
const stringArray = s.anyOf( const stringArray = s.anyOf(
[ [
@@ -25,7 +23,7 @@ const stringArray = s.anyOf(
if (v.includes(",")) { if (v.includes(",")) {
return v.split(","); return v.split(",");
} }
return [v]; return [v].filter(Boolean);
} }
return []; return [];
}, },
@@ -80,6 +78,8 @@ const where = s.anyOf([s.string(), s.object({})], {
}, },
], ],
coerce: (value: unknown) => { coerce: (value: unknown) => {
if (value === undefined || value === null || value === "") return {};
const q = typeof value === "string" ? JSON.parse(value) : value; const q = typeof value === "string" ? JSON.parse(value) : value;
return WhereBuilder.convert(q); return WhereBuilder.convert(q);
}, },
@@ -97,9 +97,9 @@ export type RepoWithSchema = Record<
} }
>; >;
const withSchema = <Type = unknown>(self: Schema): Schema<{}, Type, Type> => const withSchema = <Type = unknown>(self: s.Schema): s.Schema<{}, Type, Type> =>
s.anyOf([stringIdentifier, s.array(stringIdentifier), self], { s.anyOf([stringIdentifier, s.array(stringIdentifier), self], {
coerce: function (this: typeof anyOf, _value: unknown, opts: CoercionOptions = {}) { coerce: function (this: typeof s.anyOf, _value: unknown, opts: s.CoercionOptions = {}) {
let value: any = _value; let value: any = _value;
if (typeof value === "string") { if (typeof value === "string") {
+1 -2
View File
@@ -1,6 +1,5 @@
import { transformObject } from "core/utils"; import { transformObject, s } from "bknd/utils";
import { TaskMap, TriggerMap } from "flows"; import { TaskMap, TriggerMap } from "flows";
import { s } from "bknd/utils";
export const TASKS = { export const TASKS = {
...TaskMap, ...TaskMap,
+1 -1
View File
@@ -2,7 +2,7 @@ import { Event, EventManager, type ListenerHandler } from "core/events";
import type { EmitsEvents } from "core/events"; import type { EmitsEvents } from "core/events";
import type { Task, TaskResult } from "../tasks/Task"; import type { Task, TaskResult } from "../tasks/Task";
import type { Flow } from "./Flow"; import type { Flow } from "./Flow";
import { $console } from "core/utils"; import { $console } from "bknd/utils";
export type TaskLog = TaskResult & { export type TaskLog = TaskResult & {
task: Task; task: Task;
+1 -1
View File
@@ -1,4 +1,4 @@
import { $console, transformObject } from "core/utils"; import { $console, transformObject } from "bknd/utils";
import { type TaskMapType, TriggerMap } from "../index"; import { type TaskMapType, TriggerMap } from "../index";
import type { Task } from "../tasks/Task"; import type { Task } from "../tasks/Task";
import { Condition, TaskConnection } from "../tasks/TaskConnection"; import { Condition, TaskConnection } from "../tasks/TaskConnection";
@@ -1,5 +1,5 @@
import type { Task } from "../../tasks/Task"; import type { Task } from "../../tasks/Task";
import { $console } from "core/utils"; import { $console } from "bknd/utils";
export class RuntimeExecutor { export class RuntimeExecutor {
async run( async run(

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