Compare commits

..

98 Commits

Author SHA1 Message Date
dswbx 4c11789ea8 Fix Release 0.11.1 (#150)
* fix strategy forms handling, add register route and hidden fields

Refactored strategy forms to include hidden fields for type and name. Added a registration route with necessary adjustments to the admin controller and routes. Corrected field handling within relevant forms and components.

* fix admin access permissions and refactor routing structure

display a fixed error for unmet permissions when retrieving the schema. moved auth routes outside of BkndProvider and reorganized remaining routes to include BkndWrapper.

* fix: properly type BkndWrapper

* bump fix release version

* ModuleManager: update diff checking and AppData validation

Revised diff handling includes validation of diffs, reverting changes on failure, and enforcing module constraints with onBeforeUpdate hooks. Introduced `validateDiffs` and backup of stable configs. Applied changes in related modules, tests, and UI layer to align with updated diff logic.

* fix: cli: running from config file were using invalid args

* fix: cli: improve sequence of onBuilt trigger to allow custom routes from cli

* fix e2e tests
2025-04-20 09:29:58 +02:00
dswbx 2988e4c3bd fix: force json-schema-library version 2025-04-18 16:06:32 +02:00
dswbx ad7926db4c Merge pull request #121 from bknd-io/release/0.11
Release 0.11
2025-04-08 12:51:34 +02:00
dswbx 53a3dcdee7 release: v0.11 2025-04-08 12:50:37 +02:00
dswbx 53467d6750 fix: create: cloudflare starter wasn't creating a r2 bucket 2025-04-08 12:43:50 +02:00
dswbx a80a731498 fix: cli: user command now uses the same app env setup as run 2025-04-08 11:22:10 +02:00
dswbx 7e1757b7f4 fix: updated cloudflare adapter to use runtime config, aligned vite 2025-04-05 18:03:58 +02:00
dswbx 2c29e06fb8 fix: media infinite should be disabled for dropzone in entities 2025-04-05 18:01:04 +02:00
dswbx ca86fa58ac cli: user: add token generation (#140)
* cli: user: add token generation

* cli: user: add token generation

* cli: user: check for value being cancel before continuing
2025-04-05 17:57:03 +02:00
dswbx de984fa101 fixes issues in firefox where view transitions are not available (#139) 2025-04-04 09:00:50 +02:00
dswbx a12d4e13d0 e2e: added script to auto test adapters 2025-04-03 16:40:51 +02:00
dswbx fa6c7acaf5 implement/init e2e tests (#135)
* init e2e

* updated/moved vitest, finished merge

* fix bun picking up e2e tests

* e2e: overwrite webserver config with env

* e2e: added adapter configs

* e2e: replaced image
2025-04-03 11:08:16 +02:00
dswbx 0b41aa5a2d cli: create: allow non-interactive create (#137) 2025-04-03 09:17:29 +02:00
dswbx a5ec40c517 docker: add option to overwrite bknd version used (#136) 2025-04-03 07:58:00 +02:00
dswbx 75e2b96344 fixed limbo batching issue by disabling batching (#133)
* fixed limbo batching issue by disabling batching

* updated @libsql/client to `0.15.2`
2025-04-02 20:19:20 +02:00
dswbx aaae8d9681 aws cli create: added guided db creation (#134) 2025-04-02 20:18:52 +02:00
dswbx e4608b7df7 cosmetics: fixed admin skeleton, use password field on auth, use $console in auth middleware 2025-04-01 13:49:58 +02:00
dswbx 44b3f72005 added media overlay preview fallback 2025-04-01 13:37:11 +02:00
dswbx 9134d121cd keep extension from file when generating random name (#127)
* keep extension from file when generating random name

* added test for random name generation
2025-04-01 13:24:32 +02:00
Cameron Pak 2f067451b4 chore: update dependencies and enhance TypeScript configuration for Astro example (#128) 2025-04-01 12:58:13 +02:00
dswbx 3f26c45dd9 refactored adapters to run test suites (#126)
* refactored adapters to run test suites

* fix bun version for tests

* added missing adapter tests and refactored examples to use `bknd.config.ts` where applicable
2025-04-01 11:43:11 +02:00
dswbx 36e4224b33 refactored EventManager to run asyncs on call only, app defaults to run before response (#129)
* refactored EventManager to run asyncs on call only, app defaults to run before response

* fix tests
2025-04-01 11:19:55 +02:00
dswbx 434d56672c Merge pull request #125 from bknd-io/feat/cli-load-creds
improve cli creds extraction
2025-03-29 08:11:43 +01:00
dswbx b2fd907e8c updated docs, fixed run with node/tsx 2025-03-28 21:12:50 +01:00
dswbx b29c04e8c9 added more cli instructions 2025-03-28 20:52:00 +01:00
dswbx 11a28eba88 improve cli creds extraction 2025-03-28 18:03:09 +01:00
dswbx 9e3c081e50 reorganized storage adapter and added test suites for adapter and fields (#124)
* reorganized storage adapter and added test suites for adapter and fields

* added build command in ci pipeline

* updated workflow to also run node tests

* updated workflow: try with separate tasks

* updated workflow: try with separate tasks

* updated workflow: added tsx as dev dependency

* updated workflow: try with find instead of glob
2025-03-27 20:41:42 +01:00
dswbx 40c9ef9d90 Merge pull request #123 from bknd-io/feat/media-dialog-and-infinite
add media detail dialog and infinite loading
2025-03-27 10:24:48 +01:00
dswbx 7facef47da fix media styling on mobile 2025-03-27 09:57:31 +01:00
dswbx e2bf6a0724 Merge remote-tracking branch 'origin/feat/media-detect-dimensions' into feat/media-dialog-and-infinite 2025-03-27 09:26:09 +01:00
dswbx f6a511d998 add media detail dialog and infinite loading 2025-03-27 09:23:14 +01:00
dswbx 9407f3d212 add image dimension detection for most common formats 2025-03-27 09:21:58 +01:00
dswbx 0424c08a9e Merge pull request #120 from bknd-io/feat/cf-create-infra
cli create: improve cloudflare (create d1, r2)
2025-03-26 10:32:48 +01:00
dswbx 9d122da4b9 cli create: improve cloudflare (create d1, r2) 2025-03-26 10:30:47 +01:00
dswbx 0f56d554b0 Merge pull request #119 from bknd-io/fix/cf-d1-introspect-issue-2
fix d1 introspect issue by excluding `_cf_METADATA`
2025-03-26 09:59:38 +01:00
dswbx 06e92d268b fix d1 introspect issue by excluding _cf_METADATA 2025-03-26 09:58:49 +01:00
dswbx b44b87923b Merge pull request #118 from bknd-io/fix/cf-d1-introspect-issue
fix d1 introspect issue by excluding `_cf_METADATA`
2025-03-26 09:21:59 +01:00
dswbx 6e6c65b375 fix d1 introspect issue by excluding _cf_METADATA 2025-03-26 09:19:54 +01:00
dswbx c9c00adf6c docs: added database overview 2025-03-25 14:21:00 +01:00
dswbx ee15150c78 Merge pull request #109 from bknd-io/release/0.10
Release 0.10
2025-03-25 13:04:25 +01:00
dswbx f8f5ef9c98 bump version to 0.10 2025-03-25 13:03:15 +01:00
dswbx ec015b7849 docs: add postgres and sqlocal instructions 2025-03-25 13:02:09 +01:00
dswbx 67e0374c04 add change set to mutator insert/update after event 2025-03-21 19:32:24 +01:00
dswbx 9380091ba9 Merge pull request #116 from bknd-io/fix/s3-upload-in-node
fix s3 media upload in node environments by adding content length to request
2025-03-21 18:05:04 +01:00
dswbx aa66a81b27 Merge pull request #114 from bknd-io/feat/admin-colors-and-resize
admin ui: started color centralization + made sidebar resizable
2025-03-21 18:04:26 +01:00
dswbx 0c15ec1434 hide oauth client details with type password 2025-03-21 18:03:45 +01:00
dswbx 7b8c7f1ae4 fix s3 media upload in node environments by adding content length to request 2025-03-21 18:01:44 +01:00
dswbx f6996b1953 admin ui: started color centralization + made sidebar resizable 2025-03-18 10:56:39 +01:00
dswbx ea2aa7c76c fix react example styling 2025-03-15 16:44:58 +01:00
dswbx 2531c2d8d4 updated react example with todo example 2025-03-15 16:37:03 +01:00
dswbx 0e81e14421 bump rc version 2025-03-15 15:08:04 +01:00
dswbx b994c2dfb0 bump rc version 2025-03-15 15:07:29 +01:00
dswbx 4085d569a8 Merge pull request #113 from bknd-io/feat/data-sqlocal-connection
add sqlocal connection including example
2025-03-15 14:43:03 +01:00
dswbx 622a7b2b9a add sqlocal connection including example 2025-03-15 14:40:41 +01:00
dswbx 5697b7891a update cli description for run 2025-03-14 15:37:51 +01:00
dswbx 6a6a7a9f26 Merge pull request #112 from bknd-io/feat/react-router-adapter
replaced remix with react-router
2025-03-14 15:36:27 +01:00
dswbx b763826754 replaced remix with react-router 2025-03-14 15:32:43 +01:00
dswbx b3a0ecbe6d Merge pull request #111 from bknd-io/feat/cli-telemetry
added basic cli telemetry
2025-03-14 11:46:17 +01:00
dswbx 181443b320 fix is_toggled test 2025-03-14 11:45:02 +01:00
dswbx 6015acb914 added basic cli telemetry 2025-03-14 11:40:56 +01:00
dswbx a55d93b5ff data schema: disable primary field 2025-03-14 10:03:40 +01:00
dswbx 1ed94716bd Merge pull request #105 from bknd-io/feat/add-postgres-and-prepare-others
support for postgres and others
2025-03-14 09:57:44 +01:00
dswbx 178838e06c remove postgres test from main package 2025-03-14 09:55:00 +01:00
dswbx c4aaa5a90b move postgres to a separate package 2025-03-14 09:42:51 +01:00
dswbx b1b2e97a5a Merge remote-tracking branch 'origin/release/0.10' into feat/add-postgres-and-prepare-others
# Conflicts:
#	app/package.json
#	bun.lock
2025-03-14 08:06:50 +01:00
dswbx e9f2da82d7 Merge pull request #110 from bknd-io/fix/nextjs-linting-and-adapter
fix/nextjs-linting-and-adapter
2025-03-14 07:59:38 +01:00
dswbx 9cba9ada84 Merge remote-tracking branch 'origin/release/0.10' into fix/nextjs-linting-and-adapter 2025-03-14 07:56:37 +01:00
dswbx c8dcf964bc Merge pull request #103 from bknd-io/feat/remove-admin-config
remove admin config, improve theme handling
2025-03-14 07:55:43 +01:00
dswbx 1ea22248b4 fix nextjs linting hints and improve adapter to support config as function 2025-03-14 07:55:05 +01:00
dswbx 4f01004262 update override in examples 2025-03-11 19:53:31 +01:00
dswbx 051c508254 add override to skeleton 2025-03-11 19:52:44 +01:00
dswbx a72b11e9fb fix tests 2025-03-11 17:45:27 +01:00
dswbx 96781f5d3d removed admin config from server, theme is now client side, fixed module manager migrations 2025-03-11 17:41:33 +01:00
dswbx de89e26ffb Merge remote-tracking branch 'origin/release/0.10' into feat/remove-admin-config
# Conflicts:
#	app/src/modules/server/AdminController.tsx
#	app/src/ui/Admin.tsx
2025-03-11 13:56:27 +01:00
dswbx 166a7d4d0a Merge pull request #108 from bknd-io/feat/update-react-deps
upgrade dependencies
2025-03-11 13:52:21 +01:00
dswbx 640f015812 upgrade tailwind to v4 2025-03-11 13:43:40 +01:00
dswbx d90f14c707 upgrade hook form resolvers 2025-03-11 10:17:07 +01:00
dswbx 0922d90aff upgrade hook form 2025-03-11 10:11:57 +01:00
dswbx 81ca7c0e28 upgrade typebox 2025-03-11 10:08:56 +01:00
dswbx 4f87f6857f upgrade liquid, fix editor themes 2025-03-11 09:57:05 +01:00
dswbx 0dd29c3b07 fix test typings 2025-03-11 09:38:14 +01:00
dswbx eb451a8e1a upgrade kysely and hono 2025-03-11 09:29:08 +01:00
dswbx 37df32b51b upgrade fast-xml-parser (prepare to replace) 2025-03-11 09:27:40 +01:00
dswbx 722d0bd29e upgrade cf json schema, liquid, xyflow, jotai 2025-03-11 09:02:38 +01:00
dswbx 03d3ab8b80 upgrade swr and wouter 2025-03-11 08:59:33 +01:00
dswbx dc2d39fa14 upgrade dev dependencies 2025-03-11 08:57:01 +01:00
dswbx 368c60d7c7 upgrade vite to 6 2025-03-11 08:51:37 +01:00
dswbx 8df30f4dae upgrade to react 19 2025-03-11 08:44:49 +01:00
dswbx 2d7257d2bc updated react-form to latest 2025-03-11 06:49:38 +01:00
dswbx 7e2de1bd0c add a fixed release to docker build 2025-03-10 13:05:09 +01:00
dswbx b786fbcd13 Merge pull request #107 from bknd-io/hotfix/0.9.1-docker-open
hotfix 0.9.1 docker fails due to trying to open a browser
2025-03-10 12:37:02 +01:00
dswbx eabae5d3df hotfix 0.9.1 docker fails due to trying to open a browser 2025-03-10 12:28:38 +01:00
dswbx a4cb012ce8 added batching for postgres 2025-03-07 19:40:58 +01:00
dswbx 3704cad53a added BaseIntrospector and reorganized connections 2025-03-07 17:20:37 +01:00
dswbx 25a3cb9655 ignore pg test for now (missing env on ci) 2025-03-07 15:04:44 +01:00
dswbx a5c422d45d initialized postgres support 2025-03-07 15:02:19 +01:00
dswbx 8550aef606 update nextjs example to add credentials 2025-03-07 08:56:35 +01:00
dswbx e82b72275b moved flash message, removed theme from admin controller 2025-03-04 11:21:33 +01:00
360 changed files with 10591 additions and 5566 deletions
+11 -3
View File
@@ -15,12 +15,20 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
bun-version: "1.2.5"
- name: Install dependencies
working-directory: ./app
run: bun install
- name: Run tests
- name: Build
working-directory: ./app
run: bun run test
run: bun run build:ci
- name: Run Bun tests
working-directory: ./app
run: bun run test:bun
- name: Run Node tests
working-directory: ./app
run: npm run test:node
+1 -1
View File
@@ -42,7 +42,7 @@ Creating digital products always requires developing both the backend (the logic
* 🏃‍♂️ Multiple run modes
* standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd)
* using a React framework (Astro, Remix, Next.js)
* using a React framework (Next.js, React Router, Astro)
* 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components
+4
View File
@@ -0,0 +1,4 @@
playwright-report
test-results
bknd.config.*
__test__/helper.d.ts
+14 -2
View File
@@ -1,9 +1,9 @@
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { App } from "../src";
import { getDummyConnection } from "./helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
afterEach(afterAllCleanup);
describe("App tests", async () => {
test("boots and pongs", async () => {
@@ -12,4 +12,16 @@ describe("App tests", async () => {
//expect(await app.data?.em.ping()).toBeTrue();
});
/*test.only("what", async () => {
const app = new App(dummyConnection, {
auth: {
enabled: true,
},
});
await app.module.auth.build();
await app.module.data.build();
console.log(app.em.entities.map((e) => e.name));
console.log(await app.em.schema().getDiff());
});*/
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+62
View File
@@ -0,0 +1,62 @@
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
it("makes config", () => {
expect(adapter.makeConfig({})).toEqual({});
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
// merges everything returned from `app` with the config
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({
env: { TEST: "test" },
} as any);
});
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
{
initialConfig: { server: { cors: { origin: "random" } } },
},
undefined,
{ id },
);
const second = await adapter.createAdapterApp();
const third = await adapter.createAdapterApp(undefined, undefined, { id });
await first.build();
await second.build();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
+11 -1
View File
@@ -5,7 +5,8 @@ import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype";
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper";
import { schemaToEm } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -64,6 +65,15 @@ describe("DataApi", () => {
const res = await req;
expect(res.data).toEqual(payload as any);
}
{
// make sure sort is working
const req = await api.readMany("posts", {
select: ["title"],
sort: "-id",
});
expect(req.data).toEqual(payload.reverse() as any);
}
});
it("updates many", async () => {
+1 -1
View File
@@ -32,7 +32,7 @@ describe("MediaApi", () => {
host,
basepath,
});
expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`);
expect(api.getFileUploadUrl({ path: "path" } as any)).toBe(`${host}${basepath}/upload/path`);
});
it("should have correct upload headers", () => {
+84 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { type App, createApp } from "../../src/App";
import { App, createApp } from "../../src/App";
import * as proto from "../../src/data/prototype";
describe("App", () => {
@@ -51,4 +51,87 @@ describe("App", () => {
expect(todos[0]?.title).toBe("ctx");
expect(todos[1]?.title).toBe("api");
});
test("lifecycle events are triggered", async () => {
const firstBoot = mock(() => null);
const configUpdate = mock(() => null);
const appBuilt = mock(() => null);
const appRequest = mock(() => null);
const beforeResponse = mock(() => null);
const app = createApp();
app.emgr.onEvent(
App.Events.AppFirstBoot,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppFirstBoot);
expect(event.params.app.version()).toBe(app.version());
firstBoot();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBuiltEvent,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBuiltEvent);
expect(event.params.app.version()).toBe(app.version());
appBuilt();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
() => {
configUpdate();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppRequest,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppRequest);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.request).toBeInstanceOf(Request);
appRequest();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBeforeResponse,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBeforeResponse);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.response).toBeInstanceOf(Response);
beforeResponse();
},
"sync",
);
await app.build();
expect(firstBoot).toHaveBeenCalled();
expect(appBuilt).toHaveBeenCalled();
//expect(configUpdate).toHaveBeenCalled();
expect(appRequest).not.toHaveBeenCalled();
expect(beforeResponse).not.toHaveBeenCalled();
});
test("emgr exec modes", async () => {
const called = mock(() => null);
const app = createApp({
options: {
asyncEventsMode: "sync",
},
});
// register async listener
app.emgr.onEvent(App.Events.AppFirstBoot, async () => {
called();
});
await app.build();
await app.server.request(new Request("http://localhost"));
// expect async listeners to be executed sync after request
expect(called).toHaveBeenCalled();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
describe("repros", async () => {
/**
@@ -3,12 +3,14 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
const ALL_TESTS = !!process.env.ALL_TESTS;
// @todo: add mock response
describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({
return;
/*const strategy = new OAuthStrategy({
type: "oidc",
client: {
client_id: process.env.OAUTH_CLIENT_ID,
client_secret: process.env.OAUTH_CLIENT_SECRET,
client_id: process.env.OAUTH_CLIENT_ID!,
client_secret: process.env.OAUTH_CLIENT_SECRET!,
},
name: "google",
});
@@ -19,13 +21,9 @@ describe("OAuthStrategy", async () => {
const config = await strategy.getConfig();
console.log("config", JSON.stringify(config, null, 2));
const request = await strategy.request({
redirect_uri,
state,
});
const server = Bun.serve({
fetch: async (req) => {
console.log("req", req.method, req.url);
const url = new URL(req.url);
if (url.pathname === "/auth/google/callback") {
console.log("req", req);
@@ -39,8 +37,13 @@ describe("OAuthStrategy", async () => {
return new Response("Bun!");
},
});
const request = await strategy.request({
redirect_uri,
state,
});
console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000));
});
});*/
});
+8 -6
View File
@@ -70,6 +70,9 @@ describe("EventManager", async () => {
new SpecialEvent({ foo: "bar" });
new InformationalEvent();
// execute asyncs
await emgr.executeAsyncs();
expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled();
});
@@ -80,15 +83,11 @@ describe("EventManager", async () => {
call();
return Promise.all(p);
};
const emgr = new EventManager(
{ InformationalEvent },
{
asyncExecutor,
},
);
const emgr = new EventManager({ InformationalEvent });
emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent());
await emgr.executeAsyncs(asyncExecutor);
expect(call).toHaveBeenCalled();
});
@@ -125,6 +124,9 @@ describe("EventManager", async () => {
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0");
await emgr.executeAsyncs();
expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled();
});
@@ -1,57 +0,0 @@
import * as assert from "node:assert/strict";
import { createWriteStream } from "node:fs";
import { after, beforeEach, describe, test } from "node:test";
import { Miniflare } from "miniflare";
import {
CloudflareKVCacheItem,
CloudflareKVCachePool,
} from "../../../src/core/cache/adapters/CloudflareKvCache";
import { runTests } from "./cache-test-suite";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
describe("CloudflareKv", async () => {
let mf: Miniflare;
runTests({
createCache: async () => {
if (mf) {
await mf.dispose();
}
mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["TEST"],
});
const kv = await mf.getKVNamespace("TEST");
return new CloudflareKVCachePool(kv as any);
},
createItem: (key, value) => new CloudflareKVCacheItem(key, value),
tester: {
test,
beforeEach,
expect: (actual?: any) => {
return {
toBe(expected: any) {
assert.equal(actual, expected);
},
toEqual(expected: any) {
assert.deepEqual(actual, expected);
},
toBeUndefined() {
assert.equal(actual, undefined);
},
};
},
},
});
after(async () => {
await mf?.dispose();
});
});
-15
View File
@@ -1,15 +0,0 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache";
import { runTests } from "./cache-test-suite";
describe("MemoryCache", () => {
runTests({
createCache: async () => new MemoryCache(),
createItem: (key, value) => new MemoryCacheItem(key, value),
tester: {
test,
beforeEach,
expect,
},
});
});
-84
View File
@@ -1,84 +0,0 @@
//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test";
import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface";
export type TestOptions = {
createCache: () => Promise<ICachePool>;
createItem: (key: string, value: any) => ICacheItem;
tester: {
test: (name: string, fn: () => Promise<void>) => void;
beforeEach: (fn: () => Promise<void>) => void;
expect: (actual?: any) => {
toBe(expected: any): void;
toEqual(expected: any): void;
toBeUndefined(): void;
};
};
};
export function runTests({ createCache, createItem, tester }: TestOptions) {
let cache: ICachePool<string>;
const { test, beforeEach, expect } = tester;
beforeEach(async () => {
cache = await createCache();
});
test("getItem returns correct item", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
const retrievedItem = await cache.get("key1");
expect(retrievedItem.value()).toEqual(item.value());
});
test("getItem returns new item when key does not exist", async () => {
const retrievedItem = await cache.get("key1");
expect(retrievedItem.key()).toEqual("key1");
expect(retrievedItem.value()).toBeUndefined();
});
test("getItems returns correct items", async () => {
const item1 = createItem("key1", "value1");
const item2 = createItem("key2", "value2");
await cache.save(item1);
await cache.save(item2);
const retrievedItems = await cache.getMany(["key1", "key2"]);
expect(retrievedItems.get("key1")?.value()).toEqual(item1.value());
expect(retrievedItems.get("key2")?.value()).toEqual(item2.value());
});
test("hasItem returns true when item exists and is a hit", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("clear and deleteItem correctly clear the cache and delete items", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
if (cache.supports().clear) {
await cache.clear();
} else {
await cache.delete("key1");
}
expect(await cache.has("key1")).toBe(false);
});
test("save correctly saves items to the cache", async () => {
const item = createItem("key1", "value1");
await cache.save(item);
expect(await cache.has("key1")).toBe(true);
});
test("putItem correctly puts items in the cache ", async () => {
await cache.put("key1", "value1", { ttl: 60 });
const item = await cache.get("key1");
expect(item.value()).toEqual("value1");
expect(item.hit()).toBe(true);
});
/*test("commit returns true", async () => {
expect(await cache.commit()).toBe(true);
});*/
}
+1
View File
@@ -14,6 +14,7 @@ describe("env", () => {
expect(is_toggled(1)).toBe(true);
expect(is_toggled(0)).toBe(false);
expect(is_toggled("anything else")).toBe(false);
expect(is_toggled(undefined, true)).toBe(true);
});
test("env()", () => {
+72 -52
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { Perf, datetimeStringUTC, isBlob, ucFirst } from "../../src/core/utils";
import { Perf, ucFirst } from "../../src/core/utils";
import * as utils from "../../src/core/utils";
import { assetsPath } from "../helper";
async function wait(ms: number) {
return new Promise((resolve) => {
@@ -75,57 +76,6 @@ describe("Core Utils", async () => {
const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
});
describe("guards", () => {
const types = {
blob: new Blob(),
file: new File([""], "file.txt"),
stream: new ReadableStream(),
arrayBuffer: new ArrayBuffer(10),
arrayBufferView: new Uint8Array(new ArrayBuffer(10)),
};
const fns = [
[utils.isReadableStream, "stream"],
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isArrayBuffer, "arrayBuffer"],
[utils.isArrayBufferView, "arrayBufferView"],
] as const;
const additional = [0, 0.0, "", null, undefined, {}, []];
for (const [fn, type, _to_test] of fns) {
test(`is${ucFirst(type)}`, () => {
const to_test = _to_test ?? (Object.keys(types) as string[]);
for (const key of to_test) {
const value = types[key as keyof typeof types];
const result = fn(value);
expect(result).toBe(key === type);
}
for (const value of additional) {
const result = fn(value);
expect(result).toBe(false);
}
});
}
});
test("getContentName", () => {
const name = "test.json";
const text = "attachment; filename=" + name;
const headers = new Headers({
"Content-Disposition": text,
});
const request = new Request("http://example.com", {
headers,
});
expect(utils.getContentName(text)).toBe(name);
expect(utils.getContentName(headers)).toBe(name);
expect(utils.getContentName(request)).toBe(name);
});
});
describe("perf", async () => {
@@ -246,6 +196,76 @@ describe("Core Utils", async () => {
});
});
describe("file", async () => {
describe("type guards", () => {
const types = {
blob: new Blob(),
file: new File([""], "file.txt"),
stream: new ReadableStream(),
arrayBuffer: new ArrayBuffer(10),
arrayBufferView: new Uint8Array(new ArrayBuffer(10)),
};
const fns = [
[utils.isReadableStream, "stream"],
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isArrayBuffer, "arrayBuffer"],
[utils.isArrayBufferView, "arrayBufferView"],
] as const;
const additional = [0, 0.0, "", null, undefined, {}, []];
for (const [fn, type, _to_test] of fns) {
test(`is${ucFirst(type)}`, () => {
const to_test = _to_test ?? (Object.keys(types) as string[]);
for (const key of to_test) {
const value = types[key as keyof typeof types];
const result = fn(value);
expect(result).toBe(key === type);
}
for (const value of additional) {
const result = fn(value);
expect(result).toBe(false);
}
});
}
});
test("getContentName", () => {
const name = "test.json";
const text = "attachment; filename=" + name;
const headers = new Headers({
"Content-Disposition": text,
});
const request = new Request("http://example.com", {
headers,
});
expect(utils.getContentName(text)).toBe(name);
expect(utils.getContentName(headers)).toBe(name);
expect(utils.getContentName(request)).toBe(name);
});
test.only("detectImageDimensions", async () => {
// wrong
// @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
// successful ones
const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any;
expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({
width: 362,
height: 387,
});
expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({
width: 453,
height: 512,
});
});
});
describe("dates", () => {
test.only("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
+2 -2
View File
@@ -27,7 +27,7 @@ describe("Relations", async () => {
const sql1 = schema
.createTable("posts")
.addColumn(...r1.schema()!)
.addColumn(...em.connection.getFieldSchema(r1.schema())!)
.compile().sql;
expect(sql1).toBe(
@@ -43,7 +43,7 @@ describe("Relations", async () => {
const sql2 = schema
.createTable("posts")
.addColumn(...r2.schema()!)
.addColumn(...em.connection.getFieldSchema(r2.schema())!)
.compile().sql;
expect(sql2).toBe(
+3
View File
@@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => {
test("events were fired", async () => {
const { data } = await mutator.insertOne({ label: "test" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
await mutator.updateOne(data.id, { label: "test2" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
await mutator.deleteOne(data.id);
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
});
+55 -6
View File
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely";
import { Perf } from "../../../src/core/utils";
import { Perf } from "core/utils";
import {
Entity,
EntityManager,
@@ -8,7 +8,10 @@ import {
ManyToOneRelation,
RepositoryEvents,
TextField,
} from "../../../src/data";
entity as $entity,
text as $text,
em as $em,
} from "data";
import { getDummyConnection } from "../helper";
type E = Kysely<any> | Transaction<any>;
@@ -177,6 +180,47 @@ describe("[Repository]", async () => {
const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true);
});
test("option: silent", async () => {
const em = $em({
items: $entity("items", {
label: $text(),
}),
}).proto.withConnection(getDummyConnection().dummyConnection);
// should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result
expect(
em
.repo("items", { silent: true })
.findMany({})
.then((r) => r.data),
).resolves.toEqual([]);
});
test("option: includeCounts", async () => {
const em = $em({
items: $entity("items", {
label: $text(),
}),
}).proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
expect(
em
.repo("items")
.findMany({})
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([0, 0]);
expect(
em
.repo("items", { includeCounts: false })
.findMany({})
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([undefined, undefined]);
});
});
describe("[data] Repository (Events)", async () => {
@@ -198,22 +242,27 @@ describe("[data] Repository (Events)", async () => {
});
test("events were fired", async () => {
await em.repository(items).findId(1);
const repo = em.repository(items);
await repo.findId(1);
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findOne({ id: 1 });
await repo.findOne({ id: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findMany({ where: { id: 1 } });
await repo.findMany({ where: { id: 1 } });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findManyByReference(1, "categories");
await repo.findManyByReference(1, "categories");
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
@@ -15,7 +15,7 @@ describe("SchemaManager tests", async () => {
const em = new EntityManager([entity], dummyConnection, [], [index]);
const schema = new SchemaManager(em);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]);
const introspection = schema.getIntrospectionFromEntity(em.entities[0]!);
expect(introspection).toEqual({
name: "test",
isView: false,
@@ -109,7 +109,7 @@ describe("SchemaManager tests", async () => {
await schema.sync({ force: true, drop: true });
const diffAfter = await schema.getDiff();
console.log("diffAfter", diffAfter);
//console.log("diffAfter", diffAfter);
expect(diffAfter.length).toBe(0);
await kysely.schema.dropTable(table).execute();
@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test";
import { SqliteIntrospector } from "data/connection";
import { getDummyDatabase } from "../../helper";
import { Kysely, SqliteDialect } from "kysely";
function create() {
const database = getDummyDatabase().dummyDb;
return new Kysely({
dialect: new SqliteDialect({ database }),
});
}
function createLibsql() {
const database = getDummyDatabase().dummyDb;
return new Kysely({
dialect: new SqliteDialect({ database }),
});
}
describe("SqliteIntrospector", () => {
test("asdf", async () => {
const kysely = create();
await kysely.schema
.createTable("test")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("string", "text", (col) => col.notNull())
.addColumn("number", "integer")
.execute();
await kysely.schema
.createIndex("idx_test_string")
.on("test")
.columns(["string"])
.unique()
.execute();
await kysely.schema
.createTable("test2")
.addColumn("id", "integer", (col) => col.primaryKey().autoIncrement().notNull())
.addColumn("number", "integer")
.execute();
await kysely.schema.createIndex("idx_test2_number").on("test2").columns(["number"]).execute();
const introspector = new SqliteIntrospector(kysely, {});
const result = await introspector.getTables();
//console.log(_jsonp(result));
expect(result).toEqual([
{
name: "test",
isView: false,
columns: [
{
name: "id",
dataType: "INTEGER",
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
comment: undefined,
},
{
name: "string",
dataType: "TEXT",
isNullable: false,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
{
comment: undefined,
dataType: "INTEGER",
hasDefaultValue: false,
isAutoIncrementing: false,
isNullable: true,
name: "number",
},
],
},
{
name: "test2",
isView: false,
columns: [
{
name: "id",
dataType: "INTEGER",
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
comment: undefined,
},
{
name: "number",
dataType: "INTEGER",
isNullable: true,
isAutoIncrementing: false,
hasDefaultValue: false,
comment: undefined,
},
],
},
]);
});
});
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { BooleanField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] BooleanField", async () => {
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" });
fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" });
test("transformRetrieve", async () => {
const field = new BooleanField("test");
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test";
import { DateField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc";
import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] DateField", async () => {
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" });
fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" });
// @todo: add datefield tests
test("week", async () => {
@@ -1,13 +1,15 @@
import { describe, expect, test } from "bun:test";
import { EnumField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
function options(strings: string[]) {
return { type: "strings", values: strings };
}
describe("[data] EnumField", async () => {
runBaseFieldTests(
fieldTestSuite(
{ expect, test },
// @ts-ignore
EnumField,
{ defaultValue: "a", schemaType: "text" },
{ options: options(["a", "b", "c"]) },
@@ -15,11 +17,13 @@ describe("[data] EnumField", async () => {
test("yields if default value is not a valid option", async () => {
expect(
// @ts-ignore
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
).toThrow();
});
test("transformPersist (config)", async () => {
// @ts-ignore
const field = new EnumField("test", { options: options(["a", "b", "c"]) });
expect(transformPersist(field, null)).resolves.toBeUndefined();
@@ -29,6 +33,7 @@ describe("[data] EnumField", async () => {
test("transformRetrieve", async () => {
const field = new EnumField("test", {
// @ts-ignore
options: options(["a", "b", "c"]),
default_value: "a",
required: true,
+14 -8
View File
@@ -1,23 +1,29 @@
import { describe, expect, test } from "bun:test";
import { Default, parse, stripMark } from "../../../../src/core/utils";
import { Field, type SchemaResponse, TextField, baseFieldConfigSchema } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { Default, stripMark } from "../../../../src/core/utils";
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] Field", async () => {
class FieldSpec extends Field {
schema(): SchemaResponse {
return this.useSchemaHelper("text");
}
getSchema() {
return baseFieldConfigSchema;
}
}
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
test("fieldSpec", () => {
expect(new FieldSpec("test").schema()).toEqual({
name: "test",
type: "text",
nullable: true, // always true
dflt: undefined, // never using default value
});
});
fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" });
test("default config", async () => {
const config = Default(baseFieldConfigSchema, {});
expect(stripMark(new FieldSpec("test").config)).toEqual(config);
expect(stripMark(new FieldSpec("test").config)).toEqual(config as any);
});
test("transformPersist (specific)", async () => {
@@ -1,19 +1,13 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils";
import {
Entity,
EntityIndex,
type EntityManager,
Field,
type SchemaResponse,
} from "../../../../src/data";
import { Entity, EntityIndex, Field } from "../../../../src/data";
class TestField extends Field {
protected getSchema(): any {
return Type.Any();
}
schema(em: EntityManager<any>): SchemaResponse {
override schema() {
return undefined as any;
}
}
@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test";
import { JsonField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] JsonField", async () => {
const field = new JsonField("test");
runBaseFieldTests(JsonField, {
fieldTestSuite({ expect, test }, JsonField, {
defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1],
schemaType: "text",
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test";
import { JsonSchemaField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc";
import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] JsonSchemaField", async () => {
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" });
// @ts-ignore
fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" });
// @todo: add JsonSchemaField tests
});
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { NumberField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] NumberField", async () => {
test("transformPersist (config)", async () => {
@@ -15,5 +15,5 @@ describe("[data] NumberField", async () => {
expect(transformPersist(field2, 10000)).resolves.toBe(10000);
});
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" });
fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" });
});
@@ -10,7 +10,12 @@ describe("[data] PrimaryField", async () => {
test("schema", () => {
expect(field.name).toBe("primary");
expect(field.schema()).toEqual(["primary", "integer", expect.any(Function)]);
expect(field.schema()).toEqual({
name: "primary",
type: "integer" as const,
nullable: false,
primary: true,
});
});
test("hasDefault", async () => {
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { TextField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc";
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] TextField", async () => {
test("transformPersist (config)", async () => {
@@ -11,5 +11,5 @@ describe("[data] TextField", async () => {
expect(transformPersist(field, "abc")).resolves.toBe("abc");
});
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" });
fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" });
});
+47
View File
@@ -4,6 +4,9 @@ import Database from "libsql";
import { format as sqlFormat } from "sql-formatter";
import { type Connection, EntityManager, SqliteLocalConnection } from "../src/data";
import type { em as protoEm } from "../src/data/prototype";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { slugify } from "core/utils/strings";
export function getDummyDatabase(memory: boolean = true): {
dummyDb: SqliteDatabase;
@@ -71,3 +74,47 @@ export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): En
export const assetsPath = `${import.meta.dir}/_assets`;
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
export async function enableFetchLogging() {
const originalFetch = global.fetch;
// @ts-ignore
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init);
const url = input instanceof URL || typeof input === "string" ? input : input.url;
// Only clone if it's a supported content type
const contentType = response.headers.get("content-type") || "";
const isSupported =
contentType.includes("json") ||
contentType.includes("text") ||
contentType.includes("xml");
if (isSupported) {
const clonedResponse = response.clone();
let extension = "txt";
let body: string;
if (contentType.includes("json")) {
body = JSON.stringify(await clonedResponse.json(), null, 2);
extension = "json";
} else if (contentType.includes("xml")) {
body = await clonedResponse.text();
extension = "xml";
} else {
body = await clonedResponse.text();
}
const fileName = `${new Date().getTime()}_${init?.method ?? "GET"}_${slugify(String(url))}.${extension}`;
const filePath = join(assetsTmpPath, fileName);
await writeFile(filePath, body);
}
return response;
};
return () => {
global.fetch = originalFetch;
};
}
@@ -1,9 +1,12 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -64,6 +67,7 @@ const configs = {
function createAuthApp() {
const app = createApp({
connection: dummyConnection,
initialConfig: {
auth: configs.auth,
},
+5 -5
View File
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(() => {
@@ -39,8 +39,8 @@ function makeName(ext: string) {
return randomString(10) + "." + ext;
}
/*beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);*/
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test.only("accepts direct", async () => {
@@ -56,9 +56,9 @@ describe("MediaController", () => {
console.log(result);
expect(result.name).toBe(name);
/*const destFile = Bun.file(assetsTmpPath + "/" + name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();*/
await destFile.delete();
});
test("accepts form data", async () => {
+5 -3
View File
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage";
import { type FileBody, Storage } from "../../src/media/storage/Storage";
import * as StorageEvents from "../../src/media/storage/events";
import { StorageAdapter } from "media";
class TestAdapter implements StorageAdapter {
class TestAdapter extends StorageAdapter {
files: Record<string, FileBody> = {};
getName() {
@@ -61,7 +62,7 @@ describe("Storage", async () => {
test("uploads a file", async () => {
const {
meta: { type, size },
} = await storage.uploadFile("hello", "world.txt");
} = await storage.uploadFile("hello" as any, "world.txt");
expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
});
@@ -71,6 +72,7 @@ describe("Storage", async () => {
});
test("events were fired", async () => {
await storage.emgr.executeAsyncs();
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
// @todo: file access must be tested in controllers
@@ -1,34 +0,0 @@
import * as assert from "node:assert/strict";
import { createWriteStream } from "node:fs";
import { test } from "node:test";
import { Miniflare } from "miniflare";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
test("what", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"],
});
const bucket = await mf.getR2Bucket("BUCKET");
console.log(await bucket.put("count", "1"));
const object = await bucket.get("count");
if (object) {
/*const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);*/
console.log("yo -->", await object.text());
assert.strictEqual(await object.text(), "1");
}
await mf.dispose();
});
@@ -1,63 +0,0 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageCloudinaryAdapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const {
CLOUDINARY_CLOUD_NAME,
CLOUDINARY_API_KEY,
CLOUDINARY_API_SECRET,
CLOUDINARY_UPLOAD_PRESET,
} = dotenvOutput.parsed!;
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => {
if (ALL_TESTS) return;
const adapter = new StorageCloudinaryAdapter({
cloud_name: CLOUDINARY_CLOUD_NAME as string,
api_key: CLOUDINARY_API_KEY as string,
api_secret: CLOUDINARY_API_SECRET as string,
upload_preset: CLOUDINARY_UPLOAD_PRESET as string,
});
const file = Bun.file(`${import.meta.dir}/icon.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
test("object exists", async () => {
expect(await adapter.objectExists("7fCTBi6L8c.png")).toBeTrue();
process.exit();
});
test("puts object", async () => {
expect(await adapter.objectExists(filename)).toBeFalse();
const result = await adapter.putObject(filename, file);
console.log("result", result);
expect(result).toBeDefined();
expect(result?.name).toBe(filename);
});
test("object exists", async () => {
await Bun.sleep(10000);
const one = await adapter.objectExists(_filename);
const two = await adapter.objectExists(filename);
expect(await adapter.objectExists(filename)).toBeTrue();
});
test("object meta", async () => {
const result = await adapter.getObjectMeta(filename);
console.log("objectMeta:result", result);
expect(result).toBeDefined();
expect(result.type).toBe("image/png");
expect(result.size).toBeGreaterThan(0);
});
test("list objects", async () => {
const result = await adapter.listObjects();
console.log("listObjects:result", result);
});
});
@@ -1,47 +0,0 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter";
import { assetsPath, assetsTmpPath } from "../../helper";
describe("StorageLocalAdapter", () => {
const adapter = new StorageLocalAdapter({
path: assetsTmpPath,
});
const file = Bun.file(`${assetsPath}/image.png`);
const _filename = randomString(10);
const filename = `${_filename}.png`;
let objects = 0;
test("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
});
test("lists objects", async () => {
expect((await adapter.listObjects()).length).toBe(objects + 1);
});
test("file exists", async () => {
expect(await adapter.objectExists(filename)).toBeTrue();
});
test("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBeTrue();
// @todo: check the content
});
test("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size,
});
});
test("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
expect(await adapter.objectExists(filename)).toBeFalse();
});
});
@@ -1,98 +0,0 @@
import { describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv";
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
dotenvOutput.parsed!;
// @todo: mock r2/s3 responses for faster tests
const ALL_TESTS = !!process.env.ALL_TESTS;
console.log("ALL_TESTS?", ALL_TESTS);
describe.skipIf(true)("StorageS3Adapter", async () => {
if (ALL_TESTS) return;
const versions = [
[
"r2",
new StorageS3Adapter({
access_key: R2_ACCESS_KEY as string,
secret_access_key: R2_SECRET_ACCESS_KEY as string,
url: R2_URL as string,
}),
],
[
"s3",
new StorageS3Adapter({
access_key: AWS_ACCESS_KEY as string,
secret_access_key: AWS_SECRET_KEY as string,
url: AWS_S3_URL as string,
}),
],
] as const;
const _conf = {
adapters: ["r2", "s3"],
tests: [
"listObjects",
"putObject",
"objectExists",
"getObject",
"deleteObject",
"getObjectMeta",
],
};
const file = Bun.file(`${import.meta.dir}/icon.png`);
const filename = `${randomString(10)}.png`;
// single (dev)
//_conf = { adapters: [/*"r2",*/ "s3"], tests: [/*"putObject",*/ "listObjects"] };
function disabled(test: (typeof _conf.tests)[number]) {
return !_conf.tests.includes(test);
}
// @todo: add mocked fetch for faster tests
describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => {
if (!_conf.adapters.includes(name) || ALL_TESTS) {
console.log("Skipping", name);
return;
}
let objects = 0;
test.skipIf(disabled("putObject"))("puts an object", async () => {
objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file)).toBeString();
});
test.skipIf(disabled("listObjects"))("lists objects", async () => {
expect((await adapter.listObjects()).length).toBe(objects + 1);
});
test.skipIf(disabled("objectExists"))("file exists", async () => {
expect(await adapter.objectExists(filename)).toBeTrue();
});
test.skipIf(disabled("getObject"))("gets an object", async () => {
const res = await adapter.getObject(filename, new Headers());
expect(res.ok).toBeTrue();
// @todo: check the content
});
test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => {
expect(await adapter.getObjectMeta(filename)).toEqual({
type: file.type, // image/png
size: file.size,
});
});
test.skipIf(disabled("deleteObject"))("deletes an object", async () => {
expect(await adapter.deleteObject(filename)).toBeUndefined();
expect(await adapter.objectExists(filename)).toBeFalse();
});
});
});
+10 -3
View File
@@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils";
describe("media/mime-types", () => {
test("tiny resolves", () => {
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
const tests = [
[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"],
] as const;
for (const [ext, mime] of tests) {
expect(tiny.guess(ext)).toBe(mime);
@@ -69,7 +71,7 @@ describe("media/mime-types", () => {
["application/zip", "zip"],
["text/tab-separated-values", "tsv"],
["application/zip", "zip"],
];
] as const;
for (const [mime, ext] of tests) {
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
@@ -86,7 +88,7 @@ describe("media/mime-types", () => {
["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
];
] as const;
for (const [filename, ext] of tests) {
expect(
@@ -94,5 +96,10 @@ describe("media/mime-types", () => {
`getRandomizedFilename(): ${filename} should end with ${ext}`,
).toBe(ext);
}
// make sure it keeps the extension, even if the file has a different type
const file = new File([""], "image.jpg", { type: "text/plain" });
const [, ext] = getRandomizedFilename(file).split(".");
expect(ext).toBe("jpg");
});
});
+5 -3
View File
@@ -4,6 +4,7 @@ import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
// @ts-ignore
import { makeCtx, moduleTestSuite } from "./module-test-suite";
describe("AppAuth", () => {
@@ -22,7 +23,7 @@ describe("AppAuth", () => {
const config = auth.toJSON();
expect(config.jwt).toBeUndefined();
expect(config.strategies.password.config).toBeUndefined();
expect(config.strategies?.password?.config).toBeUndefined();
});
test("enabling auth: generate secret", async () => {
@@ -42,6 +43,7 @@ describe("AppAuth", () => {
const auth = new AppAuth(
{
enabled: true,
// @ts-ignore
jwt: {
secret: "123456",
},
@@ -75,7 +77,7 @@ describe("AppAuth", () => {
const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1);
expect(users[0].email).toBe("some@body.com");
expect(users[0]?.email).toBe("some@body.com");
}
});
@@ -157,7 +159,7 @@ describe("AppAuth", () => {
const authField = make(name, _authFieldProto as any);
const field = users.field(name)!;
for (const prop of props) {
expect(field.config[prop]).toBe(authField.config[prop]);
expect(field.config[prop]).toEqual(authField.config[prop]);
}
}
});
+41 -3
View File
@@ -1,13 +1,51 @@
import { describe, expect, test } from "bun:test";
import { beforeEach, describe, expect, test } from "bun:test";
import { parse } from "../../src/core/utils";
import { fieldsSchema } from "../../src/data/data-schema";
import { AppData } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
import { AppData, type ModuleBuildContext } from "../../src/modules";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
import * as proto from "data/prototype";
describe("AppData", () => {
moduleTestSuite(AppData);
let ctx: ModuleBuildContext;
beforeEach(() => {
ctx = makeCtx();
});
test("field config construction", () => {
expect(parse(fieldsSchema, { type: "text" })).toBeDefined();
});
test("should prevent multi-deletion of entities in single request", async () => {
const schema = proto.em({
one: proto.entity("one", {
text: proto.text(),
}),
two: proto.entity("two", {
text: proto.text(),
}),
three: proto.entity("three", {
text: proto.text(),
}),
});
const check = () => {
const expected = ["one", "two", "three"];
const fromConfig = Object.keys(data.config.entities ?? {});
const fromEm = data.em.entities.map((e) => e.name);
expect(fromConfig).toEqual(expected);
expect(fromEm).toEqual(expected);
};
// auth must be enabled, otherwise default config is returned
const data = new AppData(schema.toJSON(), ctx);
await data.build();
check();
expect(data.schema().remove("entities")).rejects.toThrow(/more than one entity/);
check();
await data.setContext(makeCtx()).build();
check();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite";
+143 -17
View File
@@ -1,10 +1,12 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
import { entity, text } from "../../src/data";
import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils";
import { Connection, entity, text } from "../../src/data";
import { Module } from "../../src/modules/Module";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "../helper";
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
@@ -43,7 +45,7 @@ describe("ModuleManager", async () => {
}).toJSON(),
},
},
});
}) as any;
//const { version, ...json } = mm.toJSON() as any;
const c2 = getDummyConnection();
@@ -73,7 +75,7 @@ describe("ModuleManager", async () => {
}).toJSON(),
},
},
};
} as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection();
@@ -90,23 +92,20 @@ describe("ModuleManager", async () => {
await mm2.build();
expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities.test).toBeDefined();
expect(mm2.configs().data.entities.test.fields.content).toBeDefined();
expect(mm2.get("data").toJSON().entities.test.fields.content).toBeDefined();
expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
expect(mm2.get("data").toJSON().entities?.test?.fields?.content).toBeDefined();
});
test("s4: config given, table exists, version outdated, migrate", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
const version = mm.version();
const json = mm.configs();
const c2 = getDummyConnection();
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json },
});
const mm2 = new ModuleManager(c2.dummyConnection);
await mm2.syncConfigTable();
await db
@@ -171,7 +170,7 @@ describe("ModuleManager", async () => {
expect(mm2.configs().data.basepath).toBe("/api/data2");
});
test("blank app, modify config", async () => {
/*test("blank app, modify config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new ModuleManager(dummyConnection);
@@ -194,7 +193,7 @@ describe("ModuleManager", async () => {
},
},
});
});
});*/
test("partial config given", async () => {
const { dummyConnection } = getDummyConnection();
@@ -212,13 +211,15 @@ describe("ModuleManager", async () => {
expect(mm.version()).toBe(CURRENT_VERSION);
expect(mm.built()).toBe(true);
expect(mm.configs().auth.enabled).toBe(true);
expect(mm.configs().data.entities.users).toBeDefined();
expect(mm.configs().data.entities?.users).toBeDefined();
});
test("partial config given, but db version exists", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs();
const c2 = getDummyConnection();
@@ -265,8 +266,8 @@ describe("ModuleManager", async () => {
override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value < 0) {
throw new Error("value must be positive");
if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value);
}
this.setBuilt();
}
@@ -332,6 +333,7 @@ describe("ModuleManager", async () => {
// @ts-ignore
const f = mm.mutateConfigSafe("failing");
// @ts-ignore
expect(f.set({ value: 2 })).resolves.toBeDefined();
expect(mockOnUpdated).toHaveBeenCalled();
});
@@ -380,4 +382,128 @@ describe("ModuleManager", async () => {
expect(() => f.default()).toThrow();
});
});
async function getRawConfig(c: Connection) {
return (await c.kysely
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "config")
.orderBy("version", "desc")
.executeTakeFirstOrThrow()) as unknown as ConfigTable;
}
async function getDiffs(c: Connection, opts?: { dir?: "asc" | "desc"; limit?: number }) {
return await c.kysely
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "diff")
.orderBy("version", opts?.dir ?? "desc")
.$if(!!opts?.limit, (b) => b.limit(opts!.limit!))
.execute();
}
describe("diffs", () => {
test("never empty", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new ModuleManager(c);
await mm.build();
await mm.save();
expect(await getDiffs(c)).toHaveLength(0);
});
test("has timestamps", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new ModuleManager(c);
await mm.build();
await mm.get("data").schema().patch("basepath", "/api/data2");
await mm.save();
const config = await getRawConfig(c);
const diffs = await getDiffs(c);
expect(config.json.data.basepath).toBe("/api/data2");
expect(diffs).toHaveLength(1);
expect(diffs[0]!.created_at).toBeDefined();
expect(diffs[0]!.updated_at).toBeDefined();
});
});
describe("validate & revert", () => {
const schema = Type.Object({
value: Type.Array(Type.Number(), { default: [] }),
});
type SampleSchema = Static<typeof schema>;
class Sample extends Module<typeof schema> {
getSchema() {
return schema;
}
override async build() {
this.setBuilt();
}
override async onBeforeUpdate(from: SampleSchema, to: SampleSchema) {
if (to.value.length > 3) {
throw new Error("too many values");
}
if (to.value.includes(7)) {
throw new Error("contains 7");
}
return to;
}
}
class TestModuleManager extends ModuleManager {
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
super(...args);
this.modules["module1"] = new Sample({}, this.ctx());
}
}
test("respects module onBeforeUpdate", async () => {
const { dummyConnection: c } = getDummyConnection();
const mm = new TestModuleManager(c);
await mm.build();
const m = mm.get("module1" as any) as Sample;
{
expect(async () => {
await m.schema().set({ value: [1, 2, 3, 4, 5] });
return mm.save();
}).toThrow(/too many values/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 2, 3, 4, 5] });
return mm.save();
}).toThrow(/too many values/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await m.schema().set({ value: [1, 7, 5] });
return mm.save();
}).toThrow(/contains 7/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
{
expect(async () => {
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 7, 5] });
return mm.save();
}).toThrow(/contains 7/);
expect(m.config.value).toHaveLength(0);
expect((mm.configs() as any).module1.value).toHaveLength(0);
}
});
});
});
@@ -1,39 +1,44 @@
import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src";
import type { Kysely } from "kysely";
import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json";
// app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs) {
async function createVersionedApp(config: InitialModuleConfigs | any) {
const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version");
const { version, ...rest } = config;
const app = createApp({ connection: dummyConnection });
await app.build();
const db = dummyConnection.kysely as Kysely<any>;
await sql`CREATE TABLE "__bknd" (
"id" integer not null primary key autoincrement,
"version" integer,
"type" text,
"json" text,
"created_at" datetime,
"updated_at" datetime
)`.execute(db);
const qb = app.modules.ctx().connection.kysely as Kysely<any>;
const current = await qb
.selectFrom("__bknd")
.selectAll()
.where("type", "=", "config")
.executeTakeFirst();
await qb
.updateTable("__bknd")
.set("json", JSON.stringify(rest))
.set("version", 7)
.where("id", "=", current!.id)
await db
.insertInto("__bknd")
.values({
version,
type: "config",
created_at: new Date().toISOString(),
json: JSON.stringify(rest),
})
.execute();
const app2 = createApp({
const app = createApp({
connection: dummyConnection,
});
await app2.build();
return app2;
await app.build();
return app;
}
describe("Migrations", () => {
@@ -46,8 +51,8 @@ describe("Migrations", () => {
const app = await createVersionedApp(v7);
expect(app.version()).toBe(8);
expect(app.toJSON(true).auth.strategies.password.enabled).toBe(true);
expect(app.version()).toBeGreaterThan(7);
expect(app.toJSON(true).auth.strategies?.password?.enabled).toBe(true);
const req = await app.server.request("/api/auth/password/register", {
method: "POST",
@@ -60,7 +65,17 @@ describe("Migrations", () => {
}),
});
expect(req.ok).toBe(true);
const res = await req.json();
const res = (await req.json()) as any;
expect(res.user.email).toBe("test@test.com");
});
test("migration from 8 to 9", async () => {
expect(v8.version).toBe(8);
const app = await createVersionedApp(v8);
expect(app.version()).toBeGreaterThan(8);
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
});
@@ -0,0 +1,709 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"color_scheme": "light",
"logo_return_path": "/"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"brand": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"currency": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"price": {
"type": "number",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"price_compare": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"url": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"required": true,
"fillable": true,
"hidden": ["table"]
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"description": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 4
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products"
}
},
"identifier": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "jsonschema",
"config": {
"schema": {
"type": "object",
"properties": {
"size": {
"type": "string"
},
"gender": {
"type": "string"
},
"ai_description": {
"type": "string"
}
},
"additionalProperties": {
"type": ["string", "number"]
}
},
"required": false,
"fillable": true,
"hidden": ["table"]
}
},
"_likes": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"temporary": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password"]
},
"required": true,
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"username": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"name": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"_boards": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"product_likes": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "User",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Product",
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc",
"name": "Product Likes"
}
},
"boards": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"private": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"description": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "boards",
"max_items": 5
}
},
"cover": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
},
"_products": {
"type": "number",
"config": {
"default_value": 0,
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
},
"boards_products": {
"type": "generated",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"boards_id": {
"type": "relation",
"config": {
"required": true,
"reference": "boards",
"target": "boards",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"products_id": {
"type": "relation",
"config": {
"required": true,
"reference": "products",
"target": "products",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"manual": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "desc"
}
}
},
"relations": {
"poly_products_media_images": {
"type": "poly",
"source": "products",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_product_likes_users": {
"type": "n:1",
"source": "product_likes",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_product_likes_products": {
"type": "n:1",
"source": "product_likes",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"n1_boards_users": {
"type": "n:1",
"source": "boards",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": true,
"with_limit": 5
}
},
"poly_boards_media_images": {
"type": "poly",
"source": "boards",
"target": "media",
"config": {
"mappedBy": "images",
"targetCardinality": 5
}
},
"mn_boards_products_boards_products,boards_products": {
"type": "m:n",
"source": "boards",
"target": "products",
"config": {}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_product_likes_unique_products_id_users_id": {
"entity": "product_likes",
"fields": ["products_id", "users_id"],
"unique": true
},
"idx_boards_products_unique_boards_id_products_id": {
"entity": "boards_products",
"fields": ["boards_id", "products_id"],
"unique": true
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_products_identifier": {
"entity": "products",
"fields": ["identifier"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "2wY76Z$JQg(3t?Wn8g,^ZqZhlmjNx<@uQjI!7i^XZBF11Xa1>zZK2??Y[D|]cc%k",
"alg": "HS256",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
}
},
"roles": {
"guest": {
"is_default": true,
"permissions": ["system.access.api", "data.entity.read"]
},
"admin": {
"implicit_allow": true
}
},
"guard": {
"enabled": true
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/enly"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {}
}
}
@@ -0,0 +1,598 @@
{
"version": 8,
"server": {
"admin": {
"basepath": "",
"logo_return_path": "/",
"color_scheme": "dark"
},
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
]
}
},
"data": {
"basepath": "/api/data",
"entities": {
"posts": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"slug": {
"type": "text",
"config": {
"html_config": {
"element": "input"
},
"pattern": "^[a-z\\-\\_0-9]+$",
"required": false,
"fillable": true,
"hidden": false,
"label": "Slug"
}
},
"content": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": {
"rows": 5
}
},
"required": false,
"fillable": true,
"hidden": ["form"]
}
},
"active": {
"type": "boolean",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"images": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "posts"
}
},
"tags": {
"type": "jsonschema",
"config": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
},
"ui_schema": {
"ui:options": {
"orderable": false
}
},
"required": false,
"fillable": true,
"hidden": false
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "title",
"sort_dir": "desc"
}
},
"comments": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"content": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"created_at": {
"type": "date",
"config": {
"type": "date",
"required": false,
"fillable": true,
"hidden": false
}
},
"posts_id": {
"type": "relation",
"config": {
"label": "Posts",
"required": true,
"reference": "posts",
"target": "posts",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": true,
"reference": "users",
"target": "users",
"target_field": "id",
"fillable": true,
"hidden": false,
"on_delete": "set null"
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"path": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"size": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"modified_at": {
"type": "date",
"config": {
"type": "datetime",
"required": false,
"fillable": true,
"hidden": false
}
},
"reference": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"entity_id": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"metadata": {
"type": "json",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"email": {
"type": "text",
"config": {
"required": true,
"fillable": true,
"hidden": false
}
},
"strategy": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["password", "google"]
},
"required": true,
"fillable": ["create"],
"hidden": ["update", "form"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["guest", "admin", "editor"]
},
"required": false,
"fillable": true,
"hidden": false
}
},
"avatar": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "users",
"max_items": 1
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
},
"test": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"fillable": false,
"required": false,
"hidden": false
}
},
"title": {
"type": "text",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
},
"number": {
"type": "number",
"config": {
"required": false,
"fillable": true,
"hidden": false
}
}
},
"config": {
"sort_field": "id",
"sort_dir": "asc"
}
}
},
"relations": {
"n1_comments_posts": {
"type": "n:1",
"source": "comments",
"target": "posts",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_posts_media_images": {
"type": "poly",
"source": "posts",
"target": "media",
"config": {
"mappedBy": "images"
}
},
"n1_posts_users": {
"type": "n:1",
"source": "posts",
"target": "users",
"config": {
"with_limit": 5
}
},
"n1_comments_users": {
"type": "n:1",
"source": "comments",
"target": "users",
"config": {
"required": true,
"with_limit": 5
}
},
"poly_users_media_avatar": {
"type": "poly",
"source": "users",
"target": "media",
"config": {
"mappedBy": "avatar",
"targetCardinality": 1
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"jwt": {
"secret": "A%3jk*wD!Zruj123123123j$Wm8qS8m8qS8",
"alg": "HS256",
"fields": ["id", "email", "role"],
"issuer": "showoff"
},
"guard": {
"enabled": true
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": {
"hashing": "sha256"
}
},
"google": {
"enabled": true,
"type": "oauth",
"config": {
"type": "oidc",
"client": {
"client_id": "545948917277-123ieuifrag.apps.googleusercontent.com",
"client_secret": "123-123hTTZfDDGPDPp"
},
"name": "google"
}
}
},
"roles": {
"guest": {
"permissions": [
"data.entity.read",
"system.access.api",
"system.config.read"
],
"is_default": true
},
"admin": {
"is_default": false,
"implicit_allow": true
},
"editor": {
"permissions": [
"system.access.admin",
"system.config.read",
"system.schema.read",
"system.config.read.secrets",
"system.access.api",
"data.entity.read",
"data.entity.update",
"data.entity.delete",
"data.entity.create"
]
}
},
"allow_register": true,
"cookie": {
"path": "/",
"sameSite": "lax",
"secure": true,
"httpOnly": true,
"expires": 604800,
"renew": true,
"pathSuccess": "/",
"pathLoggedOut": "/"
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": {},
"adapter": {
"type": "s3",
"config": {
"access_key": "123",
"secret_access_key": "123",
"url": "https://123.r2.cloudflarestorage.com/bknd-123"
}
}
},
"flows": {
"basepath": "/api/flows",
"flows": {
"test": {
"trigger": {
"type": "http",
"config": {
"mode": "sync",
"method": "GET",
"response_type": "html",
"path": "/json-posts"
}
},
"tasks": {
"fetching": {
"type": "fetch",
"params": {
"method": "GET",
"headers": [],
"url": "https://jsonplaceholder.typicode.com/posts"
}
},
"render": {
"type": "render",
"params": {
"render": "<h1>Posts</h1>\n<ul>\n {% for post in fetching.output %}\n <li>{{ post.title }}</li>\n {% endfor %}\n</ul>"
}
}
},
"connections": {
"5cce66b5-57c6-4541-88ac-b298794c6c52": {
"source": "fetching",
"target": "render",
"config": {
"condition": {
"type": "success"
}
}
}
},
"start_task": "fetching",
"responding_task": "render"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
describe("Example Test Suite", () => {
it("should pass basic arithmetic", () => {
expect(1 + 1).toBe(2);
});
it("should handle async operations", async () => {
const result = await Promise.resolve(42);
expect(result).toBe(42);
});
});
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Automatically cleanup after each test
afterEach(() => {
cleanup();
});
-13
View File
@@ -1,13 +0,0 @@
//import type { BkndConfig } from "./src";
export default {
app: {
connection: {
type: "libsql",
config: {
//url: "http://localhost:8080"
url: ":memory:"
}
}
}
};
-257
View File
@@ -1,257 +0,0 @@
import { $, type Subprocess } from "bun";
import * as esbuild from "esbuild";
import postcss from "esbuild-postcss";
import { entryOutputMeta } from "./internal/esbuild.entry-output-meta.plugin";
import { guessMimeType } from "./src/media/storage/mime-types";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const minify = args.includes("--minify");
const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
type BuildOptions = esbuild.BuildOptions & { name: string };
const baseOptions: Partial<Omit<esbuild.BuildOptions, "plugins">> & { plugins?: any[] } = {
minify,
sourcemap,
metafile: true,
format: "esm",
drop: ["console", "debugger"],
loader: {
".svg": "dataurl",
},
define: {
__isDev: "0",
},
};
// @ts-ignore
type BuildFn = (format?: "esm" | "cjs") => BuildOptions;
// build BE
const builds: Record<string, BuildFn> = {
backend: (format = "esm") => ({
...baseOptions,
name: `backend ${format}`,
entryPoints: [
"src/index.ts",
"src/data/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/ui/index.ts",
"src/ui/main.css",
],
outdir: "dist",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
platform: "browser",
splitting: false,
bundle: true,
plugins: [postcss()],
//target: "es2022",
format,
}),
/*components: (format = "esm") => ({
...baseOptions,
name: `components ${format}`,
entryPoints: ["src/ui/index.ts", "src/ui/main.css"],
outdir: "dist/ui",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
format,
platform: "browser",
splitting: false,
//target: "es2022",
bundle: true,
//external: ["react", "react-dom", "@tanstack/react-query-devtools"],
plugins: [postcss()],
loader: {
".svg": "dataurl",
".js": "jsx"
}
}),*/
static: (format = "esm") => ({
...baseOptions,
name: `static ${format}`,
entryPoints: ["src/ui/main.tsx", "src/ui/main.css"],
entryNames: "[dir]/[name]-[hash]",
outdir: "dist/static",
outExtension: { ".js": format === "esm" ? ".js" : ".cjs" },
platform: "browser",
bundle: true,
splitting: true,
inject: ["src/ui/inject.js"],
target: "es2022",
format,
loader: {
".svg": "dataurl",
".js": "jsx",
},
define: {
__isDev: "0",
"process.env.NODE_ENV": '"production"',
},
chunkNames: "chunks/[name]-[hash]",
plugins: [
postcss(),
entryOutputMeta(async (info) => {
const manifest: Record<string, object> = {};
const toAsset = (output: string) => {
const name = output.split("/").pop()!;
return {
name,
path: output,
mime: guessMimeType(name),
};
};
for (const { output, meta } of info) {
manifest[meta.entryPoint as string] = toAsset(output);
if (meta.cssBundle) {
manifest["src/ui/main.css"] = toAsset(meta.cssBundle);
}
}
const manifest_file = "dist/static/manifest.json";
await Bun.write(manifest_file, JSON.stringify(manifest, null, 2));
console.log(`Manifest written to ${manifest_file}`, manifest);
}),
],
}),
};
function adapter(adapter: string, overrides: Partial<esbuild.BuildOptions> = {}): BuildOptions {
return {
...baseOptions,
name: `adapter ${adapter} ${overrides?.format === "cjs" ? "cjs" : "esm"}`,
entryPoints: [`src/adapter/${adapter}`],
platform: "neutral",
outfile: `dist/adapter/${adapter}/index.${overrides?.format === "cjs" ? "cjs" : "js"}`,
external: [
"cloudflare:workers",
"@hono*",
"hono*",
"bknd*",
"*.html",
"node*",
"react*",
"next*",
"libsql",
"@libsql*",
],
splitting: false,
treeShaking: true,
bundle: true,
...overrides,
};
}
const adapters = [
adapter("vite", { platform: "node" }),
adapter("cloudflare"),
adapter("nextjs", { platform: "node", format: "esm" }),
adapter("nextjs", { platform: "node", format: "cjs" }),
adapter("remix", { format: "esm" }),
adapter("remix", { format: "cjs" }),
adapter("bun"),
adapter("node", { platform: "node", format: "esm" }),
adapter("node", { platform: "node", format: "cjs" }),
];
const collect = [
builds.static(),
builds.backend(),
//builds.components(),
builds.backend("cjs"),
//builds.components("cjs"),
...adapters,
];
if (watch) {
const _state: {
timeout: Timer | undefined;
cleanup: Subprocess | undefined;
building: Subprocess | undefined;
} = {
timeout: undefined,
cleanup: undefined,
building: undefined,
};
async function rebuildTypes() {
if (!types) return;
if (_state.timeout) {
clearTimeout(_state.timeout);
if (_state.cleanup) _state.cleanup.kill();
if (_state.building) _state.building.kill();
}
_state.timeout = setTimeout(async () => {
_state.cleanup = Bun.spawn(["bun", "clean:types"], {
onExit: () => {
_state.cleanup = undefined;
_state.building = Bun.spawn(["bun", "build:types"], {
onExit: () => {
_state.building = undefined;
console.log("Types rebuilt");
},
});
},
});
}, 1000);
}
for (const { name, ...build } of collect) {
const ctx = await esbuild.context({
...build,
plugins: [
...(build.plugins ?? []),
{
name: "rebuild-notify",
setup(build) {
build.onEnd((result) => {
console.log(`rebuilt ${name} with ${result.errors.length} errors`);
rebuildTypes();
});
},
},
],
});
ctx.watch();
}
} else {
await $`rm -rf dist`;
async function _build() {
let i = 0;
const count = collect.length;
for await (const { name, ...build } of collect) {
await esbuild.build({
...build,
plugins: [
...(build.plugins || []),
{
name: "progress",
setup(build) {
i++;
build.onEnd((result) => {
const errors = result.errors.length;
const from = String(i).padStart(String(count).length);
console.log(`[${from}/${count}] built ${name} with ${errors} errors`);
});
},
},
],
});
}
console.log("All builds complete");
}
async function _buildtypes() {
if (!types) return;
Bun.spawn(["bun", "build:types"], {
onExit: () => {
console.log("Types rebuilt");
},
});
}
await Promise.all([_build(), _buildtypes()]);
}
+25 -4
View File
@@ -46,17 +46,34 @@ if (types && !watch) {
buildTypes();
}
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
}
// collection of always-external packages
const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
/**
* Building backend and general API
*/
async function buildApi() {
banner("Building API");
await tsup.build({
minify,
sourcemap,
watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
entry: [
"src/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/data/index.ts",
"src/media/index.ts",
],
outDir: "dist",
external: ["bun:test", "@libsql/client"],
external: [...external],
metafile: true,
platform: "browser",
format: ["esm"],
@@ -85,7 +102,7 @@ async function buildUi() {
sourcemap,
watch,
external: [
"bun:test",
...external,
"react",
"react-dom",
"react/jsx-runtime",
@@ -109,6 +126,7 @@ async function buildUi() {
},
} satisfies tsup.Options;
banner("Building UI");
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
@@ -119,6 +137,7 @@ async function buildUi() {
},
});
banner("Building Client");
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
@@ -136,6 +155,7 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client"
*/
async function buildUiElements() {
banner("Building UI Elements");
await tsup.build({
minify,
sourcemap,
@@ -205,6 +225,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
}
async function buildAdapters() {
banner("Building Adapters");
// base adapter handles
await tsup.build({
...baseConfig(""),
@@ -213,7 +234,7 @@ async function buildAdapters() {
});
// specific adatpers
await tsup.build(baseConfig("remix"));
await tsup.build(baseConfig("react-router"));
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
+214
View File
@@ -0,0 +1,214 @@
import { $ } from "bun";
import path from "node:path";
import c from "picocolors";
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
async function run(
cmd: string[] | string,
opts: Bun.SpawnOptions.OptionsObject & {},
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
): Promise<{ proc: Bun.Subprocess; data: any }> {
return new Promise((resolve, reject) => {
const proc = Bun.spawn(Array.isArray(cmd) ? cmd : cmd.split(" "), {
...opts,
stdout: "pipe",
stderr: "pipe",
});
// Read from stdout
const reader = proc.stdout.getReader();
const decoder = new TextDecoder();
// Function to read chunks
let resolveCalled = false;
(async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
if (!resolveCalled) {
console.log(c.dim(text.replace(/\n$/, "")));
}
onChunk(
text,
(data) => {
resolve({ proc, data });
resolveCalled = true;
},
reject,
);
}
} catch (err) {
reject(err);
}
})();
proc.exited.then((code) => {
if (code !== 0 && code !== 130) {
throw new Error(`Process exited with code ${code}`);
}
});
});
}
const adapters = {
node: {
dir: path.join(basePath, "examples/node"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
},
start: async function () {
return await run(
"npm run start",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /running on (http:\/\/.*)\n/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
bun: {
dir: path.join(basePath, "examples/bun"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
},
start: async function () {
return await run(
"npm run start",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /running on (http:\/\/.*)\n/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
cloudflare: {
dir: path.join(basePath, "examples/cloudflare-worker"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .wrangler node_modules/.cache node_modules/.mf`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Ready on (http:\/\/.*)/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
"react-router": {
dir: path.join(basePath, "examples/react-router"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .react-router data.db`;
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Local.*?(http:\/\/.*)\//;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
nextjs: {
dir: path.join(basePath, "examples/nextjs"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .nextjs data.db`;
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Local.*?(http:\/\/.*)\n/;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
astro: {
dir: path.join(basePath, "examples/astro"),
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .astro data.db`;
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
},
start: async function () {
return await run(
"npm run dev",
{
cwd: this.dir,
},
(chunk, resolve, reject) => {
const regex = /Local.*?(http:\/\/.*)\//;
if (regex.test(chunk)) {
resolve(chunk.match(regex)?.[1]);
}
},
);
},
},
} as const;
async function testAdapter(name: keyof typeof adapters) {
const config = adapters[name];
console.log("adapter", c.cyan(name));
await config.clean();
const { proc, data } = await config.start();
console.log("proc:", proc.pid, "data:", c.cyan(data));
//proc.kill();process.exit(0);
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
console.log("DONE!");
while (!proc.killed) {
proc.kill("SIGINT");
await Bun.sleep(250);
console.log("Waiting for process to exit...");
}
}
if (process.env.TEST_ADAPTER) {
await testAdapter(process.env.TEST_ADAPTER as any);
} else {
for (const [name] of Object.entries(adapters)) {
await testAdapter(name as any);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

+25
View File
@@ -0,0 +1,25 @@
// @ts-check
import { test, expect } from "@playwright/test";
import { testIds } from "../src/ui/lib/config";
import { getAdapterConfig } from "./inc/adapters";
const config = getAdapterConfig();
test("start page has expected title", async ({ page }) => {
await page.goto(config.base_path);
await expect(page).toHaveTitle(/BKND/);
});
test("start page has expected heading", async ({ page }) => {
await page.goto(config.base_path);
// Example of checking if a heading with "No entity selected" exists and is visible
const heading = page.getByRole("heading", { name: /No entity selected/i });
await expect(heading).toBeVisible();
});
test("modal opens on button click", async ({ page }) => {
await page.goto(config.base_path);
await page.getByTestId(testIds.data.btnCreateEntity).click();
await expect(page.getByRole("dialog")).toBeVisible();
});
+44
View File
@@ -0,0 +1,44 @@
const adapter = process.env.TEST_ADAPTER;
const default_config = {
media_adapter: "local",
base_path: "",
} as const;
const configs = {
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
};
export function getAdapterConfig(): typeof default_config {
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
return default_config;
}
+55
View File
@@ -0,0 +1,55 @@
// @ts-check
import { test, expect } from "@playwright/test";
import { testIds } from "../src/ui/lib/config";
import type { SchemaResponse } from "../src/modules/server/SystemController";
import { getAdapterConfig } from "./inc/adapters";
// Annotate entire file as serial.
test.describe.configure({ mode: "serial" });
const config = getAdapterConfig();
test("can enable media", async ({ page }) => {
await page.goto(`${config.base_path}/media/settings`);
// enable
const enableToggle = page.getByTestId(testIds.media.switchEnabled);
if ((await enableToggle.getAttribute("aria-checked")) !== "true") {
await expect(enableToggle).toBeVisible();
await enableToggle.click();
await expect(enableToggle).toHaveAttribute("aria-checked", "true");
// select local
const adapterChoice = page.locator(`css=button#adapter-${config.media_adapter}`);
await expect(adapterChoice).toBeVisible();
await adapterChoice.click();
// save
const saveBtn = page.getByRole("button", { name: /Update/i });
await expect(saveBtn).toBeVisible();
// intercept network request, wait for it to finish and get the response
const [request] = await Promise.all([
page.waitForRequest((request) => request.url().includes("api/system/schema")),
saveBtn.click(),
]);
const response = await request.response();
expect(response?.status(), "fresh config 200").toBe(200);
const body = (await response?.json()) as SchemaResponse;
expect(body.config.media.enabled, "media is enabled").toBe(true);
expect(body.config.media.adapter?.type, "correct adapter").toBe(config.media_adapter);
}
});
test("can upload a file", async ({ page }) => {
await page.goto(`${config.base_path}/media`);
// check any text to contain "Upload files"
await expect(page.getByText(/Upload files/i)).toBeVisible();
// upload a file from disk
// Start waiting for file chooser before clicking. Note no await.
const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText("Upload file").click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles("./e2e/assets/image.jpg");
});
+103 -73
View File
@@ -3,8 +3,8 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.9.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"version": "0.11.1",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
"type": "git",
@@ -15,11 +15,10 @@
},
"scripts": {
"dev": "vite",
"test": "ALL_TESTS=1 bun test --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -27,86 +26,111 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md"
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest": "vitest run",
"test:vitest:watch": "vitest",
"test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report"
},
"license": "FSL-1.1-MIT",
"dependencies": {
"@cfworker/json-schema": "^2.0.1",
"@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@hello-pangea/dnd": "^17.0.0",
"@libsql/client": "^0.14.0",
"@mantine/core": "^7.13.4",
"@sinclair/typebox": "^0.32.34",
"@tanstack/react-form": "0.19.2",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20",
"dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0",
"hono": "^4.6.12",
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.4",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.4",
"liquidjs": "^10.15.0",
"kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.2",
"swr": "^2.2.5"
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"wrangler": "^4.4.1"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.613.0",
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4",
"@mantine/modals": "^7.13.4",
"@mantine/notifications": "^7.13.4",
"@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1",
"@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.19.0",
"@hookform/resolvers": "^4.1.3",
"@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
"@rjsf/core": "5.22.2",
"@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.0.12",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.9",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"esbuild-postcss": "^0.0.4",
"jotai": "^2.10.1",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"kysely-d1": "^0.3.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"postcss": "^8.4.47",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"react-hook-form": "^7.53.1",
"posthog-js-lite": "^3.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.0.1",
"sql-formatter": "^15.4.9",
"tailwind-merge": "^2.5.4",
"tailwindcss": "^3.4.14",
"react-json-view-lite": "^2.4.1",
"sql-formatter": "^15.4.11",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.10",
"tsup": "^8.3.5",
"vite": "^5.4.10",
"vite-plugin-static-copy": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1",
"wouter": "^3.3.5"
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0"
},
"optionalDependencies": {
"@hono/node-server": "^1.13.7"
"@hono/node-server": "^1.13.8"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
"react": ">=19",
"react-dom": ">=19"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -115,47 +139,52 @@
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"require": "./dist/index.js"
},
"./ui": {
"types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs"
"require": "./dist/ui/index.js"
},
"./elements": {
"types": "./dist/types/ui/elements/index.d.ts",
"import": "./dist/ui/elements/index.js",
"require": "./dist/ui/elements/index.cjs"
"require": "./dist/ui/elements/index.js"
},
"./client": {
"types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs"
"require": "./dist/ui/client/index.js"
},
"./data": {
"types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.cjs"
"require": "./dist/data/index.js"
},
"./core": {
"types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.cjs"
"require": "./dist/core/index.js"
},
"./utils": {
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs"
"require": "./dist/core/utils/index.js"
},
"./cli": {
"types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs"
"require": "./dist/cli/index.js"
},
"./media": {
"types": "./dist/types/media/index.d.ts",
"import": "./dist/media/index.js",
"require": "./dist/media/index.js"
},
"./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs"
"require": "./dist/adapter/cloudflare/index.js"
},
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
@@ -164,37 +193,37 @@
"./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs"
"require": "./dist/adapter/vite/index.js"
},
"./adapter/nextjs": {
"types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs"
"require": "./dist/adapter/nextjs/index.js"
},
"./adapter/remix": {
"types": "./dist/types/adapter/remix/index.d.ts",
"import": "./dist/adapter/remix/index.js",
"require": "./dist/adapter/remix/index.cjs"
"./adapter/react-router": {
"types": "./dist/types/adapter/react-router/index.d.ts",
"import": "./dist/adapter/react-router/index.js",
"require": "./dist/adapter/react-router/index.js"
},
"./adapter/bun": {
"types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs"
"require": "./dist/adapter/bun/index.js"
},
"./adapter/node": {
"types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
"require": "./dist/adapter/node/index.js"
},
"./adapter/astro": {
"types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs"
"require": "./dist/adapter/astro/index.js"
},
"./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.cjs"
"require": "./dist/adapter/aws/index.js"
},
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
@@ -224,8 +253,9 @@
"cloudflare",
"nextjs",
"remix",
"react-router",
"astro",
"bun",
"node"
]
}
}
+42
View File
@@ -0,0 +1,42 @@
import { defineConfig, devices } from "@playwright/test";
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
export default defineConfig({
testMatch: "**/*.e2e-spec.ts",
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 20000,
use: {
baseURL: baseUrl,
trace: "on-first-retry",
video: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
/* {
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
}, */
],
webServer: autoStart
? {
command: startCommand,
url: baseUrl,
reuseExistingServer: !process.env.CI,
}
: undefined,
});
+1 -4
View File
@@ -1,9 +1,6 @@
export default {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
"postcss-preset-mantine": {},
"postcss-simple-vars": {
variables: {
+4
View File
@@ -78,6 +78,10 @@ export class Api {
this.buildApis();
}
get fetcher() {
return this.options.fetcher ?? fetch;
}
get baseUrl() {
return this.options.host ?? "http://localhost";
}
+75 -34
View File
@@ -4,9 +4,10 @@ import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
ModuleManager,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
} from "modules/ModuleManager";
@@ -16,6 +17,7 @@ import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be there
import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";
export type AppPlugin = (app: App) => Promise<void> | void;
@@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent {
export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export class AppRequest extends AppEvent<{ request: Request }> {
static override slug = "app-request";
}
export class AppBeforeResponse extends AppEvent<{ request: Request; response: Response }> {
static override slug = "app-before-response";
}
export const AppEvents = {
AppConfigUpdatedEvent,
AppBuiltEvent,
AppFirstBoot,
AppRequest,
AppBeforeResponse,
} as const;
export type AppOptions = {
plugins?: AppPlugin[];
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
};
export type CreateAppConfig = {
connection?:
@@ -53,12 +68,14 @@ export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App {
modules: ModuleManager;
static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _id: string = crypto.randomUUID();
private _building: boolean = false;
constructor(
@@ -70,35 +87,9 @@ export class App {
this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}),
initial: _initialConfig,
onUpdated: async (key, config) => {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", key);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
$console.log("App first boot");
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
server.use(async (c, next) => {
c.set("app", this);
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
});
},
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
@@ -189,7 +180,10 @@ export class App {
registerAdminController(config?: AdminControllerOptions) {
// register admin
this.adminController = new AdminController(this, config);
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
this.modules.server.route(
this.adminController.basepath,
this.adminController.getController(),
);
return this;
}
@@ -213,6 +207,53 @@ export class App {
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
}
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", module);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
async onFirstBoot() {
$console.log("App first boot");
this.trigger_first_boot = true;
}
async onServerInit(server: Hono<ServerEnv>) {
server.use(async (c, next) => {
c.set("app", this);
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
await this.emgr.emit(
new AppBeforeResponse({ app: this, request: c.req.raw, response: c.res }),
);
// execute collected async events (async by default)
switch (this.options?.asyncEventsMode ?? "async") {
case "sync":
await this.emgr.executeAsyncs();
break;
case "async":
this.emgr.executeAsyncs();
break;
}
});
}
}
export function createApp(config: CreateAppConfig = {}) {
+90
View File
@@ -0,0 +1,90 @@
import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { App } from "App";
export function adapterTestSuite<
Config extends BkndConfig = BkndConfig,
Args extends DefaultArgs = DefaultArgs,
>(
testRunner: TestRunner,
{
makeApp,
makeHandler,
label = "app",
overrides = {},
}: {
makeApp: (
config: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
label?: string;
overrides?: {
dbUrl?: string;
};
},
) {
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
const onBuilt = mock(async () => null) as any;
const config = {
app: (env) => ({
connection: { url: env.url },
initialConfig: {
server: { cors: { origin: env.origin } },
},
}),
beforeBuild,
onBuilt,
} as const satisfies BkndConfig;
const app = await makeApp(
config as any,
{
url: overrides.dbUrl ?? ":memory:",
origin: "localhost",
} as any,
{ id },
);
expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost");
expect(beforeBuild).toHaveBeenCalledTimes(1);
expect(onBuilt).toHaveBeenCalledTimes(1);
});
if (makeHandler) {
const getConfig = async (fetcher: (r: Request) => Promise<Response>) => {
const res = await fetcher(new Request("http://localhost:3000/api/system/config"));
const data = (await res.json()) as any;
return { res, data };
};
test("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("localhost");
});
test("creates fresh & responds to api config", async () => {
// set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
});
}
}
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as astro from "./astro.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
});
});
+16 -25
View File
@@ -1,34 +1,25 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = {
request: Request;
};
export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export type Options = {
mode?: "static" | "dynamic";
} & Omit<ApiOptions, "host"> & {
host?: string;
};
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
const api = new Api({
host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined,
});
await api.verifyAuth();
return api;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts: FrameworkOptions = {},
) {
return await createFrameworkApp(config, args ?? import.meta.env, opts);
}
let app: App;
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: Context) => {
if (!app) {
app = await createFrameworkApp(config, args);
}
return app.fetch(args.request);
export function serve<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
};
}
+62 -54
View File
@@ -1,68 +1,76 @@
import type { App } from "bknd";
import { handle } from "hono/aws-lambda";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
export type AwsLambdaBkndConfig = RuntimeBkndConfig & {
assets?:
| {
mode: "local";
root: string;
}
| {
mode: "url";
url: string;
};
};
type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
RuntimeBkndConfig<Env> & {
assets?:
| {
mode: "local";
root: string;
}
| {
mode: "url";
url: string;
};
};
let app: App;
export async function createApp({
adminOptions = false,
assets,
...config
}: AwsLambdaBkndConfig = {}) {
if (!app) {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
};
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
};
if (assets?.mode) {
switch (assets.mode) {
case "local":
// @todo: serve static outside app context
additional = {
adminOptions: adminOptions === false ? undefined : adminOptions,
serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({
root: assets.root,
onFound: (path, c) => {
c.res.headers.set("Cache-Control", "public, max-age=31536000");
},
}),
};
break;
case "url":
additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}),
assets_path: assets.url,
};
break;
default:
throw new Error("Invalid assets mode");
}
if (assets?.mode) {
switch (assets.mode) {
case "local":
// @todo: serve static outside app context
additional = {
adminOptions: adminOptions === false ? undefined : adminOptions,
serveStatic: serveStatic({
root: assets.root,
onFound: (path, c) => {
c.res.headers.set("Cache-Control", "public, max-age=31536000");
},
}),
};
break;
case "url":
additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}),
assetsPath: assets.url,
};
break;
default:
throw new Error("Invalid assets mode");
}
app = await createRuntimeApp({
...config,
...additional,
});
}
return app;
return await createRuntimeApp(
{
...config,
...additional,
},
args ?? process.env,
opts,
);
}
export function serveLambda(config: AwsLambdaBkndConfig = {}) {
console.log("serving lambda");
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => {
const app = await createApp(config);
const app = await createApp(config, args, opts);
return await handle(app.server)(event);
};
}
// compatibility with old code
export const serveLambda = serve;
+19
View File
@@ -0,0 +1,19 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as awsLambda from "./aws-lambda.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o);
return app.fetch(request);
},
});
});
+15
View File
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as bun from "./bun.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("bun adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: bun.createApp,
makeHandler: bun.createHandler,
});
});
+47 -29
View File
@@ -1,47 +1,64 @@
/// <reference types="bun-types" />
import path from "node:path";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
let app: App;
type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
export async function createApp<Env = BunEnv>(
{ distPath, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
return await createRuntimeApp(
{
...config,
serveStatic: serveStatic({ root }),
});
}
return app;
},
args ?? (process.env as Env),
opts,
);
}
export function serve({
distPath,
connection,
initialConfig,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
...serveOptions
}: BunBkndConfig = {}) {
export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts);
return app.fetch(req);
};
}
export function serve<Env = BunEnv>(
{
distPath,
connection,
initialConfig,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
...serveOptions
}: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
Bun.serve({
...serveOptions,
port,
fetch: async (request: Request) => {
const app = await createApp({
fetch: createHandler(
{
connection,
initialConfig,
options,
@@ -49,9 +66,10 @@ export function serve({
buildConfig,
adminOptions,
distPath,
});
return app.fetch(request);
},
},
args,
opts,
),
});
console.log(`Server is running on http://localhost:${port}`);
+7
View File
@@ -0,0 +1,7 @@
import { expect, test, mock } from "bun:test";
export const bunTestRunner = {
expect,
test,
mock,
};
+7 -7
View File
@@ -12,12 +12,16 @@ export type D1ConnectionConfig = {
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV"],
excludeTables: ["_cf_KV", "_cf_METADATA"],
});
}
}
export class D1Connection extends SqliteConnection {
protected override readonly supported = {
batching: true,
};
constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()];
@@ -28,12 +32,8 @@ export class D1Connection extends SqliteConnection {
super(kysely, {}, plugins);
}
override supportsBatching(): boolean {
return true;
}
override supportsIndices(): boolean {
return true;
get client(): D1Database {
return this.config.binding;
}
protected override async batch<Queries extends QB[]>(
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("cf adapter", () => {
const DB_URL = ":memory:";
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
request: request ?? (null as any),
env: env ?? { DB_URL },
ctx: ctx ?? (null as any),
});
it("makes config", async () => {
expect(
makeConfig(
{
connection: { url: DB_URL },
},
{},
),
).toEqual({ connection: { url: DB_URL } });
expect(
makeConfig(
{
app: (env) => ({
connection: { url: env.DB_URL },
}),
},
{
DB_URL,
},
),
).toEqual({ connection: { url: DB_URL } });
});
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
makeHandler: (c, a, o) => {
return async (request: any) => {
const app = await makeApp(
// needs a fallback, otherwise tries to launch D1
c ?? {
connection: { url: DB_URL },
},
a,
o,
);
return app.fetch(request);
};
},
});
});
@@ -1,18 +1,17 @@
/// <reference types="@cloudflare/workers-types" />
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import { D1Connection } from "./D1Connection";
import { registerMedia } from "./StorageR2Adapter";
import { getBinding } from "./bindings";
import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh";
import type { App } from "bknd";
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Context<Env>) => {
bindings?: (args: Env) => {
kv?: KVNamespace;
dobj?: DurableObjectNamespace;
db?: D1Database;
@@ -22,49 +21,17 @@ export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>>
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
setAdminHtml?: boolean;
html?: string;
};
export type Context<Env = any> = {
export type Context<Env = CloudflareEnv> = {
request: Request;
env: Env;
ctx: ExecutionContext;
};
let media_registered: boolean = false;
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
if (!media_registered) {
registerMedia(context.env as any);
media_registered = true;
}
const appConfig = makeConfig(config, context);
const bindings = config.bindings?.(context);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(context.env ?? {}).length > 0) {
const binding = getBinding(context.env, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
return appConfig;
}
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
) {
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
@@ -75,7 +42,7 @@ export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
throw new Error("manifest is required with static 'kv'");
}
if (config.manifest && config.static !== "assets") {
if (config.manifest && config.static === "kv") {
const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) {
@@ -99,21 +66,27 @@ export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
}
}
const context = { request, env, ctx } as Context;
const context = { request, env, ctx } as Context<Env>;
const mode = config.mode ?? "warm";
let app: App;
switch (mode) {
case "fresh":
return await getFresh(config, context);
app = await getFresh(config, context, { force: true });
break;
case "warm":
return await getWarm(config, context);
app = await getFresh(config, context);
break;
case "cache":
return await getCached(config, context);
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);
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { D1Connection } from "./D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { ExecutionContext } from "hono";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
};
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
) {
if (!media_registered) {
registerMedia(args as any);
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args);
const bindings = config.bindings?.(args);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
return appConfig;
}
export function registerAsyncsExecutionContext(
app: App,
ctx: { waitUntil: ExecutionContext["waitUntil"] },
) {
app.emgr.onEvent(
App.Events.AppBeforeResponse,
async (event) => {
ctx.waitUntil(event.params.app.emgr.executeAsyncs());
},
{
mode: "sync",
id: constants.exec_async_event_id,
},
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh";
export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig };
+9 -5
View File
@@ -1,8 +1,12 @@
import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, constants } from "../config";
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
{ env, ctx, ...args }: Context<Env>,
) {
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -16,10 +20,11 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
const app = await createRuntimeApp(
{
...makeCfConfig(config, { env, ctx, ...args }),
...makeConfig(config, env),
initialConfig,
onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
registerAsyncsExecutionContext(app, ctx);
app.module.server.client.get(constants.cache_endpoint, async (c) => {
await kv.delete(key);
return c.json({ message: "Cache cleared" });
});
@@ -35,7 +40,6 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
);
await config.beforeBuild?.(app);
},
adminOptions: { html: config.html },
},
{ env, ctx, ...args },
);
+10 -10
View File
@@ -1,9 +1,13 @@
import { DurableObject } from "cloudflare:workers";
import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
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";
@@ -17,13 +21,11 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = makeConfig(config, ctx);
const create_config = makeConfig(config, ctx.env);
const res = await stub.fire(ctx.request, {
config: create_config,
html: config.html,
keepAliveSeconds: config.keepAliveSeconds,
setAdminHtml: config.setAdminHtml,
});
const headers = new Headers(res.headers);
@@ -67,7 +69,8 @@ export class DurableBkndApp extends DurableObject {
this.app = await createRuntimeApp({
...config,
onBuilt: async (app) => {
app.modules.server.get("/__do", async (c) => {
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({
@@ -92,7 +95,6 @@ export class DurableBkndApp extends DurableObject {
this.keepAlive(options.keepAliveSeconds);
}
console.log("id", this.id);
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
@@ -106,19 +108,17 @@ export class DurableBkndApp extends DurableObject {
}
async onBuilt(app: App) {}
async beforeBuild(app: App) {}
protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
//console.log("keep-alive", i);
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
+24 -22
View File
@@ -1,27 +1,29 @@
import type { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext } from "../config";
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp(
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
return await makeApp(
{
...makeCfConfig(config, ctx),
adminOptions: config.html ? { html: config.html } : undefined,
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
config.onBuilt?.(app);
},
},
ctx,
ctx.env,
opts,
);
}
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
const app = await makeApp(config, ctx);
return app.fetch(ctx.request);
}
let warm_app: App;
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
if (!warm_app) {
warm_app = await makeApp(config, ctx);
}
return warm_app.fetch(ctx.request);
}
@@ -0,0 +1,32 @@
import { createWriteStream, readFileSync } from "node:fs";
import { test } from "node:test";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node";
import path from "node:path";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
test("StorageR2Adapter", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"],
});
const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
const adapter = new StorageR2Adapter(bucket);
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(nodeTestRunner, adapter, file);
await mf.dispose();
});
@@ -1,9 +1,8 @@
import { registries } from "bknd";
import { isDebug } from "bknd/core";
import { StringEnum, Type } from "bknd/utils";
import type { FileBody, StorageAdapter } from "media/storage/Storage";
import { guess } from "media/storage/mime-types-tiny";
import { getBindings } from "./bindings";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings";
export function makeSchema(bindings: string[] = []) {
return Type.Object(
@@ -47,8 +46,10 @@ export function registerMedia(env: Record<string, any>) {
* Adapter for R2 storage
* @todo: add tests (bun tests won't work, need node native tests)
*/
export class StorageR2Adapter implements StorageAdapter {
constructor(private readonly bucket: R2Bucket) {}
export class StorageR2Adapter extends StorageAdapter {
constructor(private readonly bucket: R2Bucket) {
super();
}
getName(): string {
return "r2";
+80 -43
View File
@@ -12,76 +12,113 @@ export type BkndConfig<Args = any> = CreateAppConfig & {
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type CreateAdapterAppOptions = {
force?: boolean;
id?: string;
};
export type FrameworkOptions = CreateAdapterAppOptions;
export type RuntimeOptions = CreateAdapterAppOptions;
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
adminOptions?: AdminControllerOptions | false;
};
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
export type DefaultArgs = {
[key: string]: any;
};
export function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>,
args?: Args,
): CreateAppConfig {
let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) {
if (typeof config.app === "function") {
const { app, ...rest } = config;
if (app) {
if (typeof app === "function") {
if (!args) {
throw new Error("args is required when config.app is a function");
}
additionalConfig = config.app(args);
additionalConfig = app(args);
} else {
additionalConfig = config.app;
additionalConfig = app;
}
}
return { ...config, ...additionalConfig };
return { ...rest, ...additionalConfig };
}
export async function createFrameworkApp<Args = any>(
config: FrameworkBkndConfig,
// a map that contains all apps by id
const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config,
args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> {
const app = App.create(makeConfig(config, args));
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
app = App.create(makeConfig(config, args));
apps.set(id, app);
}
return app;
}
if (config.onBuilt) {
export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {},
args?: Args,
opts?: FrameworkOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
if (!app.isBuilt()) {
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await config.onBuilt?.(app);
},
"sync",
);
}
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
}
return app;
}
export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args,
opts?: RuntimeOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
if (!app.isBuilt()) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
if (serveStatic) {
const [path, handler] = Array.isArray(serveStatic)
? serveStatic
: [$config.server.assets_path + "*", serveStatic];
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
},
"sync",
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
}
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
export async function createRuntimeApp<Env = any>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig,
env?: Env,
): Promise<App> {
const app = App.create(makeConfig(config, env));
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
if (serveStatic) {
const [path, handler] = Array.isArray(serveStatic)
? serveStatic
: [$config.server.assets_path + "*", serveStatic];
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
},
"sync",
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
@@ -0,0 +1,16 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as nextjs from "./nextjs.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { NextjsBkndConfig } from "./nextjs.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("nextjs adapter", () => {
adapterTestSuite<NextjsBkndConfig>(bunTestRunner, {
makeApp: nextjs.getApp,
makeHandler: nextjs.serve,
});
});
+18 -26
View File
@@ -1,29 +1,19 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { getRuntimeKey, isNode } from "core/utils";
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
export type NextjsBkndConfig = FrameworkBkndConfig & {
type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] };
};
let app: App;
let building: boolean = false;
export async function getApp(config: NextjsBkndConfig) {
if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (app) return app;
}
building = true;
if (!app) {
app = await createFrameworkApp(config);
await app.build();
}
building = false;
return app;
export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
}
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -49,11 +39,13 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
});
}
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (req: Request) => {
if (!app) {
app = await getApp(config);
}
const app = await getApp(config, args, opts);
const request = getCleanRequest(req, cleanRequest);
return app.fetch(request);
};
+12 -5
View File
@@ -1,12 +1,19 @@
import { registries } from "bknd";
import {
type LocalAdapterConfig,
StorageLocalAdapter,
} from "../../media/storage/adapters/StorageLocalAdapter";
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export { nodeTestRunner } from "./test";
let registered = false;
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
if (!registered) {
registries.media.register("local", StorageLocalAdapter);
registered = true;
}
return (config: Partial<LocalAdapterConfig> = {}) => {
const adapter = new StorageLocalAdapter(config);
return adapter.toJSON(true);
};
}
@@ -0,0 +1,15 @@
import { describe, before, after } from "node:test";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { nodeTestRunner } from "adapter/node";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog());
after(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(nodeTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
});
});
+15
View File
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("node adapter (bun)", () => {
adapterTestSuite(bunTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
});
});
+36 -23
View File
@@ -2,11 +2,11 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core";
export type NodeBkndConfig = RuntimeBkndConfig & {
type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
@@ -14,14 +14,11 @@ export type NodeBkndConfig = RuntimeBkndConfig & {
relativeDistPath?: string;
};
export function serve({
distPath,
relativeDistPath,
port = $config.server.default_port,
hostname,
listener,
...config
}: NodeBkndConfig = {}) {
export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.relative(
process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
@@ -30,23 +27,39 @@ export function serve({
console.warn("relativeDistPath is deprecated, please use distPath instead");
}
let app: App;
registerLocalMediaAdapter();
return await createRuntimeApp(
{
...config,
serveStatic: serveStatic({ root }),
},
// @ts-ignore
args ?? { env: process.env },
opts,
);
}
export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts);
return app.fetch(req);
};
}
export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
honoServe(
{
port,
hostname,
fetch: async (req: Request) => {
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
...config,
serveStatic: serveStatic({ root }),
});
}
return app.fetch(req);
},
fetch: createHandler(config, args, opts),
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
@@ -0,0 +1,17 @@
import { describe } from "node:test";
import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
import path from "node:path";
describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
const adapter = new StorageLocalAdapter({
path: path.join(basePath, "tmp"),
});
await adapterTestSuite(nodeTestRunner, adapter, file);
});
@@ -0,0 +1,15 @@
import { describe, test, expect } from "bun:test";
import { StorageLocalAdapter } from "./StorageLocalAdapter";
// @ts-ignore
import { assetsPath, assetsTmpPath } from "../../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
describe("StorageLocalAdapter (bun)", async () => {
const adapter = new StorageLocalAdapter({
path: assetsTmpPath,
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite(bunTestRunner, adapter, file);
});
@@ -1,26 +1,21 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, isFile, parse } from "core/utils";
import type {
FileBody,
FileListObject,
FileMeta,
FileUploadPayload,
StorageAdapter,
} from "../../Storage";
import { guess } from "../../mime-types-tiny";
import { type Static, Type, isFile, parse } from "bknd/utils";
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
export const localAdapterConfig = Type.Object(
{
path: Type.String({ default: "./" }),
},
{ title: "Local", description: "Local file system storage" },
{ title: "Local", description: "Local file system storage", additionalProperties: false },
);
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
export class StorageLocalAdapter implements StorageAdapter {
export class StorageLocalAdapter extends StorageAdapter {
private config: LocalAdapterConfig;
constructor(config: any) {
constructor(config: Partial<LocalAdapterConfig> = {}) {
super();
this.config = parse(localAdapterConfig, config);
}
+99
View File
@@ -0,0 +1,99 @@
import nodeAssert from "node:assert/strict";
import { test } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
const mockCalls = new WeakMap<Function, number>();
function createMockFunction<T extends (...args: any[]) => any>(fn: T): T {
const mockFn = (...args: Parameters<T>) => {
const currentCalls = mockCalls.get(mockFn) || 0;
mockCalls.set(mockFn, currentCalls + 1);
return fn(...args);
};
return mockFn as T;
}
const nodeTestMatcher = <T = unknown>(actual: T, parentFailMsg?: string) =>
({
toEqual: (expected: T, failMsg = parentFailMsg) => {
nodeAssert.deepEqual(actual, expected, failMsg);
},
toBe: (expected: T, failMsg = parentFailMsg) => {
nodeAssert.strictEqual(actual, expected, failMsg);
},
toBeString: (failMsg = parentFailMsg) => {
nodeAssert.strictEqual(typeof actual, "string", failMsg);
},
toBeUndefined: (failMsg = parentFailMsg) => {
nodeAssert.strictEqual(actual, undefined, failMsg);
},
toBeDefined: (failMsg = parentFailMsg) => {
nodeAssert.notStrictEqual(actual, undefined, failMsg);
},
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
const e = Array.isArray(expected) ? expected : [expected];
nodeAssert.ok(e.includes(actual), failMsg);
},
toHaveBeenCalled: (failMsg = parentFailMsg) => {
const calls = mockCalls.get(actual as Function) || 0;
nodeAssert.ok(calls > 0, failMsg || "Expected function to have been called at least once");
},
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
const calls = mockCalls.get(actual as Function) || 0;
nodeAssert.strictEqual(
calls,
expected,
failMsg || `Expected function to have been called ${expected} times`,
);
},
}) satisfies Matcher<T>;
const nodeTestResolverProxy = <T = unknown>(
actual: Promise<T>,
handler: { resolve?: any; reject?: any },
) => {
return new Proxy(
{},
{
get: (_, prop) => {
if (prop === "then") {
return actual.then(handler.resolve, handler.reject);
}
return actual;
},
},
) as Matcher<Awaited<T>>;
};
function nodeTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
}
nodeTest.if = (condition: boolean): Test => {
if (condition) {
return nodeTest;
}
return (() => {}) as any;
};
nodeTest.skip = (label: string, fn: TestFn) => {
return test.skip(label, fn as any);
};
nodeTest.skipIf = (condition: boolean): Test => {
if (condition) {
return (() => {}) as any;
}
return nodeTest;
};
export const nodeTestRunner: TestRunner = {
test: nodeTest,
mock: createMockFunction,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
...nodeTestMatcher(actual, failMsg),
resolves: nodeTestResolverProxy(actual as Promise<T>, {
resolve: (r) => nodeTestMatcher(r, failMsg),
}),
rejects: nodeTestResolverProxy(actual as Promise<T>, {
reject: (r) => nodeTestMatcher(r, failMsg),
}),
}),
};
+1
View File
@@ -0,0 +1 @@
export * from "./react-router.adapter";
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as rr from "./react-router.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: rr.getApp,
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
});
});
@@ -0,0 +1,26 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = {
request: Request;
};
export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return await createFrameworkApp(config, args ?? process.env, opts);
}
export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
};
}
-22
View File
@@ -1,22 +0,0 @@
import { useAuth } from "bknd/client";
import type { BkndAdminProps } from "bknd/ui";
import { Suspense, lazy, useEffect, useState } from "react";
export function adminPage(props?: BkndAdminProps) {
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
return () => {
const auth = useAuth();
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
setLoaded(true);
}, []);
if (!loaded) return null;
return (
<Suspense>
<Admin withProvider={{ user: auth.user }} {...props} />
</Suspense>
);
};
}
-2
View File
@@ -1,2 +0,0 @@
export * from "./remix.adapter";
export * from "./AdminPage";
-40
View File
@@ -1,40 +0,0 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
type RemixContext = {
request: Request;
};
let app: App;
let building: boolean = false;
export async function getApp<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args>,
args?: Args
) {
if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (app) return app;
}
building = true;
if (!app) {
app = await createFrameworkApp(config, args);
await app.build();
}
building = false;
return app;
}
export function serve<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args> = {},
) {
return async (args: Args) => {
app = await getApp(config, args);
return app.fetch(args.request);
};
}
+32 -38
View File
@@ -1,18 +1,24 @@
import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import {
type DevServerOptions,
default as honoViteDevServer,
} from "@hono/vite-dev-server";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import {
type RuntimeBkndConfig,
createRuntimeApp,
type FrameworkOptions,
} from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
mode?: "cached" | "fresh";
setAdminHtml?: boolean;
forceDev?: boolean | { mainPath: string };
html?: string;
};
export type ViteEnv = NodeJS.ProcessEnv;
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
export function addViteScript(html: string, addBkndContext: boolean = true) {
export function addViteScript(
html: string,
addBkndContext: boolean = true,
) {
return html.replace(
"</head>",
`<script type="module">
@@ -28,52 +34,40 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
);
}
async function createApp(config: ViteBkndConfig = {}, env?: any) {
async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> {
registerLocalMediaAdapter();
return await createRuntimeApp(
{
...config,
adminOptions:
config.setAdminHtml === false
? undefined
: {
html: config.html,
forceDev: config.forceDev ?? {
mainPath: "/src/main.tsx",
},
},
adminOptions: config.adminOptions ?? {
forceDev: {
mainPath: "/src/main.tsx",
},
},
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
},
env,
opts,
);
}
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
export function serve<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env);
const app = await createApp(config, env, opts);
return app.fetch(request, env, ctx);
},
};
}
let app: App;
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
if (!app) {
app = await createApp(config, env);
}
return app.fetch(request, env, ctx);
},
};
}
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
return mode === "fresh" ? serveFresh(config) : serveCached(config);
}
export function devServer(options: DevServerOptions) {
return honoViteDevServer({
...devServerConfig,
+6 -11
View File
@@ -1,4 +1,4 @@
import { type DB, Exception } from "core";
import { type DB, Exception, type PrimaryFieldType } from "core";
import { addFlashMessage } from "core/server/flash";
import {
type Static,
@@ -14,6 +14,7 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0];
@@ -37,11 +38,10 @@ export interface Strategy {
}
export type User = {
id: number;
id: PrimaryFieldType;
email: string;
username: string;
password: string;
role: string;
role?: string | null;
};
export type ProfileExchange = {
@@ -158,13 +158,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
// @todo: add jwt tests
async jwt(user: Omit<User, "password">): Promise<string> {
const prohibited = ["password"];
for (const prop of prohibited) {
if (prop in user) {
throw new Error(`Property "${prop}" is prohibited`);
}
}
async jwt(_user: Omit<User, "password">): Promise<string> {
const user = pick(_user, this.config.jwt.fields);
const payload: JWTPayload = {
...user,

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