Compare commits

...

57 Commits

Author SHA1 Message Date
dswbx 3e77982996 docs: added docs about how to use bknd.config.ts 2025-06-05 17:11:50 +02:00
dswbx 7b128c9701 Merge pull request #176 from bknd-io/chores/repo-cleanup
repo chores: fixed root dir, removed unused class, added .env.example
2025-05-27 20:27:38 +02:00
dswbx 061181d59d repo chores: fixed root dir, removed unused class, added .env.example 2025-05-27 20:25:37 +02:00
dswbx af6cb0c8f0 bump 0.13.0 + separated cli build into separate file 2025-05-27 16:53:49 +02:00
dswbx 5a693c0370 Merge pull request #169 from bknd-io/release/0.13
Release 0.13
2025-05-27 16:29:43 +02:00
dswbx 262588decc update github action to use bun 1.2.14 + added .nvmrc (24 breaks for node tests) 2025-05-27 13:30:02 +02:00
dswbx 17ab35e245 api: added custom storage option (#174) 2025-05-27 13:09:24 +02:00
dswbx db795ec050 Controllers: New validation + auto OpenAPI (#173)
* updated controllers to use custom json schema and added auto openapi specs

* fix data routes parsing body

* added schema exports to core

* added swagger link to Admin, switched use-search
2025-05-27 09:06:36 +02:00
dswbx 773df544dd feat/custom-json-schema (#172)
* init

* update

* finished new repo query, removed old implementation

* remove debug folder
2025-05-22 08:52:25 +02:00
dswbx 0ac7d1fd6e remove batching workaround for Turso AWS endpoints (#171)
the underlying issue with batching on Turso AWS endpoints appears resolved, making the workaround unnecessary.
2025-05-21 07:47:24 +02:00
dswbx 6694c63990 admin: data/auth route-driven settings and collapsible components (#168)
introduced `useRoutePathState` for managing active states via routes, added `CollapsibleList` for reusable collapsible UI, and updated various components to leverage route awareness for improved navigation state handling. Also adjusted routing for entities, strategies, and schema to support optional sub-paths.
2025-05-03 11:05:38 +02:00
dswbx b3f95f9552 v0.12.0 2025-05-01 10:13:40 +02:00
dswbx 372f94d22a Release 0.12 (#143)
* changed tb imports

* cleanup: replace console.log/warn with $console, remove commented-out code

Removed various commented-out code and replaced direct `console.log` and `console.warn` usage across the codebase with `$console` from "core" for standardized logging. Also adjusted linting rules in biome.json to enable warnings for `console.log` usage.

* ts: enable incremental

* fix imports in test files

reorganize imports to use "@sinclair/typebox" directly, replacing local utility references, and add missing "override" keywords in test classes.

* added media permissions (#142)

* added permissions support for media module

introduced `MediaPermissions` for fine-grained access control in the media module, updated routes to enforce these permissions, and adjusted permission registration logic.

* fix: handle token absence in getUploadHeaders and add tests for transport modes

ensure getUploadHeaders does not set Authorization header when token is missing. Add unit tests to validate behavior for different token_transport options.

* remove console.log on DropzoneContainer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* add bcrypt and refactored auth resolve (#147)

* reworked auth architecture with improved password handling and claims

Refactored password strategy to prepare supporting bcrypt, improving hashing/encryption flexibility. Updated authentication flow with enhanced user resolution mechanisms, safe JWT generation, and consistent profile handling. Adjusted dependencies to include bcryptjs and updated lock files accordingly.

* 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.

* refactored auth handling to support bcrypt, extracted user pool

* update email regex to allow '+' and '_' characters

* update test stub password for AppAuth spec

* update data exceptions to use HttpStatus constants, adjust logging level in AppUserPool

* rework strategies to extend a base class instead of interface

* added simple bcrypt test

* add validation logs and improve data validation handling (#157)

Added warning logs for invalid data during mutator validation, refined field validation logic to handle undefined values, and adjusted event validation comments for clarity. Minor improvements include exporting events from core and handling optional chaining in entity field validation.

* modify MediaApi to support custom fetch implementation, defaults to native fetch (#158)

* modify MediaApi to support custom fetch implementation, defaults to native fetch

added an optional `fetcher` parameter to allow usage of a custom fetch function in both `upload` and `fetcher` methods. Defaults to the standard `fetch` if none is provided.

* fix tests and improve api fetcher types

* update admin basepath handling and window context integration (#155)

Refactored `useBkndWindowContext` to include `admin_basepath` and updated its usage in routing. Improved type consistency with `AdminBkndWindowContext` and ensured default values are applied for window context.

* trigger `repository-find-[one|many]-[before|after]` based on `limit` (#160)

* refactor error handling in authenticator and password strategy (#161)

made `respondWithError` method public, updated login and register routes in `PasswordStrategy` to handle errors using `respondWithError` for consistency.

* add disableSubmitOnError prop to NativeForm and export getFlashMessage (#162)

Introduced a `disableSubmitOnError` prop to NativeForm to control submit button behavior when errors are present. Also exported `getFlashMessage` from the core for external usage.

* update dependencies in package.json (#156)

moved several dependencies between devDependencies and dependencies for better categorization and removed redundant entries.

* update imports to adjust nodeTestRunner path and remove unused export (#163)

updated imports in test files to reflect the correct path for nodeTestRunner. removed redundant export of nodeTestRunner from index file to clean up module structure. In some environments this could cause issues requiring to exclude `node:test`, just removing it for now.

* fix sync events not awaited (#164)

* refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)

Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.

* replace LiquidJs rendering with simplified renderer (#167)

* replace LiquidJs rendering with simplified renderer

Removed dependency on LiquidJS and replaced it with a custom templating solution using lodash `get`. Updated corresponding components, editors, and tests to align with the new rendering approach. Removed unused filters and tags.

* remove liquid js from package json

* feat/cli-generate-types (#166)

* init types generation

* update type generation for entities and fields

Refactored `EntityTypescript` to support improved field types and relations. Added `toType` method overrides for various fields to define accurate TypeScript types. Enhanced CLI `types` command with new options for output style and file handling. Removed redundant test files.

* update type generation code and CLI option description

removed unused imports definition, adjusted formatting in EntityTypescript, and clarified the CLI style option description.

* fix json schema field type generation

* reworked system entities to prevent recursive types

* reworked system entities to prevent recursive types

* remove unused object function

* types: use number instead of Generated

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

* update data hooks and api types

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-01 10:12:18 +02:00
dswbx d6f94a2ce1 fix docs mdx snippets imports 2025-04-26 07:34:56 +02:00
dswbx 89a39a7dc6 fix docs: try locking version 2025-04-25 08:52:52 +02:00
dswbx 88cf65f792 fix docs by tmp renaming intro page 2025-04-24 21:20:12 +02:00
dswbx 07723ce6ae bump to 0.11.2 2025-04-22 15:55:20 +02:00
dswbx 9010401af6 remove unused useTheme import and add loading state for entity detail view (#154) 2025-04-22 15:43:27 +02:00
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
372 changed files with 20184 additions and 6542 deletions
+11 -3
View File
@@ -15,12 +15,20 @@ jobs:
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v1
with: with:
bun-version: latest bun-version: "1.2.14"
- name: Install dependencies - name: Install dependencies
working-directory: ./app working-directory: ./app
run: bun install run: bun install
- name: Run tests - name: Build
working-directory: ./app 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
+2
View File
@@ -30,3 +30,5 @@ packages/media/.env
.vscode .vscode
.git_old .git_old
docker/tmp docker/tmp
.debug
.history
+1
View File
@@ -0,0 +1 @@
22
+42
View File
@@ -0,0 +1,42 @@
# ===== DB Settings =====
VITE_DB_URL=:memory:
# you can set a location for a database here, it'll overwrite the previous setting
# ideally use the ".db" folder (create it first), it's git ignored
VITE_DB_URL=file:.db/dev.db
# alternatively, you can use url/token combination
#VITE_DB_URL=
#VITE_DB_TOKEN=
# ===== DEV Server =====
# restart the dev server on every change (enable with "1")
VITE_APP_FRESH=
# displays react-scan widget (enable with "1")
VITE_DEBUG_RERENDERS=
# console logs registered routes on start (enable with "1")
VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
R2_SECRET_ACCESS_KEY=
R2_URL=
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_S3_URL=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
PUBLIC_POSTHOG_KEY=
PUBLIC_POSTHOG_HOST=
# ===== Internals =====
BKND_CLI_CREATE_REF=main
BKND_CLI_LOG_LEVEL=debug
BKND_MODULES_DEBUG=1
+4
View File
@@ -0,0 +1,4 @@
playwright-report
test-results
bknd.config.*
__test__/helper.d.ts
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 { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema"; import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype"; 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); beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
@@ -64,6 +65,15 @@ describe("DataApi", () => {
const res = await req; const res = await req;
expect(res.data).toEqual(payload as any); 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 () => { it("updates many", async () => {
+26 -4
View File
@@ -1,8 +1,8 @@
/// <reference types="@types/bun" /> /// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils"; import { getFileFromContext, isFile, isReadableStream } from "core/utils";
import { MediaApi } from "../../src/media/api/MediaApi"; import { MediaApi } from "media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper"; import { assetsPath, assetsTmpPath } from "../helper";
const mockedBackend = new Hono() const mockedBackend = new Hono()
@@ -39,10 +39,28 @@ describe("MediaApi", () => {
// @ts-ignore tests // @ts-ignore tests
const api = new MediaApi({ const api = new MediaApi({
token: "token", token: "token",
token_transport: "header",
}); });
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token"); expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
}); });
it("should return empty headers if not using `header` transport", () => {
expect(
new MediaApi({
token_transport: "cookie",
})
.getUploadHeaders()
.has("Authorization"),
).toBe(false);
expect(
new MediaApi({
token_transport: "none",
})
.getUploadHeaders()
.has("Authorization"),
).toBe(false);
});
it("should get file: native", async () => { it("should get file: native", async () => {
const name = "image.png"; const name = "image.png";
const path = `${assetsTmpPath}/${name}`; const path = `${assetsTmpPath}/${name}`;
@@ -103,8 +121,12 @@ describe("MediaApi", () => {
}); });
it("should upload file in various ways", async () => { it("should upload file in various ways", async () => {
// @ts-ignore tests const api = new MediaApi(
const api = new MediaApi({}, mockedBackend.request); {
upload_fetcher: mockedBackend.request,
},
mockedBackend.request,
);
const file = Bun.file(`${assetsPath}/image.png`); const file = Bun.file(`${assetsPath}/image.png`);
async function matches(req: Promise<any>, filename: string) { async function matches(req: Promise<any>, filename: string) {
+84 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test"; import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src"; 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"; import * as proto from "../../src/data/prototype";
describe("App", () => { describe("App", () => {
@@ -51,4 +51,87 @@ describe("App", () => {
expect(todos[0]?.title).toBe("ctx"); expect(todos[0]?.title).toBe("ctx");
expect(todos[1]?.title).toBe("api"); 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 { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { createApp, registries } from "../../src";
import * as proto from "../../src/data/prototype"; 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 () => { describe("repros", async () => {
/** /**
@@ -3,8 +3,10 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
const ALL_TESTS = !!process.env.ALL_TESTS; const ALL_TESTS = !!process.env.ALL_TESTS;
// @todo: add mock response
describe("OAuthStrategy", async () => { describe("OAuthStrategy", async () => {
const strategy = new OAuthStrategy({ return;
/*const strategy = new OAuthStrategy({
type: "oidc", type: "oidc",
client: { client: {
client_id: process.env.OAUTH_CLIENT_ID!, client_id: process.env.OAUTH_CLIENT_ID!,
@@ -21,6 +23,7 @@ describe("OAuthStrategy", async () => {
const server = Bun.serve({ const server = Bun.serve({
fetch: async (req) => { fetch: async (req) => {
console.log("req", req.method, req.url);
const url = new URL(req.url); const url = new URL(req.url);
if (url.pathname === "/auth/google/callback") { if (url.pathname === "/auth/google/callback") {
console.log("req", req); console.log("req", req);
@@ -42,5 +45,5 @@ describe("OAuthStrategy", async () => {
console.log("request", request); console.log("request", request);
await new Promise((resolve) => setTimeout(resolve, 100000)); await new Promise((resolve) => setTimeout(resolve, 100000));
}); });*/
}); });
+8 -6
View File
@@ -70,6 +70,9 @@ describe("EventManager", async () => {
new SpecialEvent({ foo: "bar" }); new SpecialEvent({ foo: "bar" });
new InformationalEvent(); new InformationalEvent();
// execute asyncs
await emgr.executeAsyncs();
expect(call).toHaveBeenCalledTimes(2); expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled(); expect(delayed).toHaveBeenCalled();
}); });
@@ -80,15 +83,11 @@ describe("EventManager", async () => {
call(); call();
return Promise.all(p); return Promise.all(p);
}; };
const emgr = new EventManager( const emgr = new EventManager({ InformationalEvent });
{ InformationalEvent },
{
asyncExecutor,
},
);
emgr.onEvent(InformationalEvent, async () => {}); emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent()); await emgr.emit(new InformationalEvent());
await emgr.executeAsyncs(asyncExecutor);
expect(call).toHaveBeenCalled(); expect(call).toHaveBeenCalled();
}); });
@@ -125,6 +124,9 @@ describe("EventManager", async () => {
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true); expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0"); expect(e2.params.foo).toBe("bar-1-0");
await emgr.executeAsyncs();
expect(onInvalidReturn).toHaveBeenCalled(); expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled(); expect(asyncEventCallback).toHaveBeenCalled();
}); });
+2 -3
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox"; import { type TObject, type TString, Type } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry"; import { Registry } from "core";
import { type TSchema, Type } from "../../src/core/utils";
type Constructor<T> = new (...args: any[]) => T; type Constructor<T> = new (...args: any[]) => T;
@@ -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,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { SchemaObject } from "../../../src/core"; import { SchemaObject } from "../../../src/core";
import { Type } from "../../../src/core/utils"; import { Type } from "@sinclair/typebox";
describe("SchemaObject", async () => { describe("SchemaObject", async () => {
test("basic", async () => { test("basic", async () => {
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query"; import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
describe("object-query", () => { describe("object-query", () => {
const q: ObjectQuery = { name: "Michael" }; const q: ObjectQuery = { name: "Michael" };
+72 -52
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; 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 * as utils from "../../src/core/utils";
import { assetsPath } from "../helper";
async function wait(ms: number) { async function wait(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -75,57 +76,6 @@ describe("Core Utils", async () => {
const result3 = utils.encodeSearch(obj3, { encode: true }); const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); 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 () => { 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", () => { describe("dates", () => {
test.only("formats local time", () => { test.only("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25"); expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
+5 -11
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Entity, NumberField, TextField } from "../../../src/data"; import { Entity, NumberField, TextField } from "data";
import * as p from "data/prototype";
describe("[data] Entity", async () => { describe("[data] Entity", async () => {
const entity = new Entity("test", [ const entity = new Entity("test", [
@@ -47,14 +48,7 @@ describe("[data] Entity", async () => {
expect(entity.getField("new_field")).toBe(field); expect(entity.getField("new_field")).toBe(field);
}); });
// @todo: move this to ClientApp test.only("types", async () => {
/*test("serialize and deserialize", async () => { console.log(entity.toTypes());
const json = entity.toJSON(); });
//sconsole.log("json", json.fields);
const newEntity = Entity.deserialize(json);
//console.log("newEntity", newEntity.toJSON().fields);
expect(newEntity).toBeInstanceOf(Entity);
expect(json).toEqual(newEntity.toJSON());
expect(json.fields).toEqual(newEntity.toJSON().fields);
});*/
}); });
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users)); em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1); expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation); expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]); expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]); expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
expect(em.hasRelations("users")).toBe(true); expect(em.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true); expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]); expect(em.relatedEntitiesOf("users")).toEqual([posts]);
+3
View File
@@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => {
test("events were fired", async () => { test("events were fired", async () => {
const { data } = await mutator.insertOne({ label: "test" }); const { data } = await mutator.insertOne({ label: "test" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
await mutator.updateOne(data.id, { label: "test2" }); await mutator.updateOne(data.id, { label: "test2" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
await mutator.deleteOne(data.id); await mutator.deleteOne(data.id);
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue(); expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
}); });
+62 -6
View File
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely"; import type { Kysely, Transaction } from "kysely";
import { Perf } from "../../../src/core/utils"; import { Perf } from "core/utils";
import { import {
Entity, Entity,
EntityManager, EntityManager,
@@ -8,7 +8,10 @@ import {
ManyToOneRelation, ManyToOneRelation,
RepositoryEvents, RepositoryEvents,
TextField, TextField,
} from "../../../src/data"; entity as $entity,
text as $text,
em as $em,
} from "data";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
type E = Kysely<any> | Transaction<any>; type E = Kysely<any> | Transaction<any>;
@@ -177,6 +180,47 @@ describe("[Repository]", async () => {
const res5 = await em.repository(items).exists({}); const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true); 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 () => { describe("[data] Repository (Events)", async () => {
@@ -198,24 +242,36 @@ describe("[data] Repository (Events)", async () => {
}); });
test("events were fired", 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.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear(); 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.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear(); 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.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); 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.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue(); expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear(); events.clear();
// check find one on findMany with limit 1
await repo.findMany({ where: { id: 1 }, limit: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
}); });
}); });
@@ -1,25 +1,18 @@
import { describe, expect, test } from "bun:test"; import { describe, test, expect } from "bun:test";
import { Value, _jsonp } from "../../src/core/utils"; import { getDummyConnection } from "../helper";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data"; import { type WhereQuery, WhereBuilder } from "data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
const decode = (input: RepoQueryIn, expected: RepoQuery) => { function qb() {
const result = Value.Decode(querySchema, input); const c = getDummyConnection();
expect(result).toEqual(expected); const kysely = c.dummyConnection.kysely;
}; return kysely.selectFrom("t").selectAll();
}
describe("data-query-impl", () => { function compile(q: WhereQuery) {
function qb() { const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
const c = getDummyConnection(); return { sql, parameters };
const kysely = c.dummyConnection.kysely; }
return kysely.selectFrom("t").selectAll();
}
function compile(q: WhereQuery) {
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
return { sql, parameters };
}
describe("WhereBuilder", () => {
test("single validation", () => { test("single validation", () => {
const tests: [WhereQuery, string, any[]][] = [ const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]], [{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
expect(keys).toEqual(expectedKeys); expect(keys).toEqual(expectedKeys);
} }
}); });
test("with", () => {
decode({ with: ["posts"] }, { with: { posts: {} } });
decode({ with: { posts: {} } }, { with: { posts: {} } });
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
decode(
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
{
with: {
posts: {
with: {
images: {
select: ["id"],
},
},
},
},
},
);
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
});
});
describe("data-query-impl: Typebox", () => {
test("sort", async () => {
const _dflt = { sort: { by: "id", dir: "asc" } };
decode({ sort: "" }, _dflt);
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
decode({ sort: "-1name" }, _dflt);
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
});
}); });
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { BooleanField } from "../../../../src/data"; import { BooleanField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] BooleanField", async () => { describe("[data] BooleanField", async () => {
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" }); fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" });
test("transformRetrieve", async () => { test("transformRetrieve", async () => {
const field = new BooleanField("test"); const field = new BooleanField("test");
@@ -1,9 +1,9 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { DateField } from "../../../../src/data"; import { DateField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] DateField", async () => { describe("[data] DateField", async () => {
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" }); fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" });
// @todo: add datefield tests // @todo: add datefield tests
test("week", async () => { test("week", async () => {
@@ -1,13 +1,15 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { EnumField } from "../../../../src/data"; import { EnumField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
function options(strings: string[]) { function options(strings: string[]) {
return { type: "strings", values: strings }; return { type: "strings", values: strings };
} }
describe("[data] EnumField", async () => { describe("[data] EnumField", async () => {
runBaseFieldTests( fieldTestSuite(
{ expect, test },
// @ts-ignore
EnumField, EnumField,
{ defaultValue: "a", schemaType: "text" }, { defaultValue: "a", schemaType: "text" },
{ options: options(["a", "b", "c"]) }, { options: options(["a", "b", "c"]) },
@@ -15,11 +17,13 @@ describe("[data] EnumField", async () => {
test("yields if default value is not a valid option", async () => { test("yields if default value is not a valid option", async () => {
expect( expect(
// @ts-ignore
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }), () => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
).toThrow(); ).toThrow();
}); });
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
// @ts-ignore
const field = new EnumField("test", { options: options(["a", "b", "c"]) }); const field = new EnumField("test", { options: options(["a", "b", "c"]) });
expect(transformPersist(field, null)).resolves.toBeUndefined(); expect(transformPersist(field, null)).resolves.toBeUndefined();
@@ -29,6 +33,7 @@ describe("[data] EnumField", async () => {
test("transformRetrieve", async () => { test("transformRetrieve", async () => {
const field = new EnumField("test", { const field = new EnumField("test", {
// @ts-ignore
options: options(["a", "b", "c"]), options: options(["a", "b", "c"]),
default_value: "a", default_value: "a",
required: true, required: true,
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Default, stripMark } from "../../../../src/core/utils"; import { Default, stripMark } from "../../../../src/core/utils";
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field"; import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] Field", async () => { describe("[data] Field", async () => {
class FieldSpec extends Field { class FieldSpec extends Field {
@@ -19,7 +19,7 @@ describe("[data] Field", async () => {
}); });
}); });
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" }); fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" });
test("default config", async () => { test("default config", async () => {
const config = Default(baseFieldConfigSchema, {}); const config = Default(baseFieldConfigSchema, {});
@@ -1,19 +1,13 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils"; import { Type } from "@sinclair/typebox";
import { import { Entity, EntityIndex, Field } from "../../../../src/data";
Entity,
EntityIndex,
type EntityManager,
Field,
type SchemaResponse,
} from "../../../../src/data";
class TestField extends Field { class TestField extends Field {
protected getSchema(): any { protected getSchema(): any {
return Type.Any(); return Type.Any();
} }
schema(em: EntityManager<any>): SchemaResponse { override schema() {
return undefined as any; return undefined as any;
} }
} }
@@ -1,10 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { JsonField } from "../../../../src/data"; import { JsonField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] JsonField", async () => { describe("[data] JsonField", async () => {
const field = new JsonField("test"); const field = new JsonField("test");
runBaseFieldTests(JsonField, { fieldTestSuite({ expect, test }, JsonField, {
defaultValue: { a: 1 }, defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1], sampleValues: ["string", { test: 1 }, 1],
schemaType: "text", schemaType: "text",
@@ -1,9 +1,10 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { JsonSchemaField } from "../../../../src/data"; import { JsonSchemaField } from "../../../../src/data";
import { runBaseFieldTests } from "./inc"; import { fieldTestSuite } from "data/fields/field-test-suite";
describe("[data] JsonSchemaField", async () => { describe("[data] JsonSchemaField", async () => {
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" }); // @ts-ignore
fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" });
// @todo: add JsonSchemaField tests // @todo: add JsonSchemaField tests
}); });
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { NumberField } from "../../../../src/data"; import { NumberField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] NumberField", async () => { describe("[data] NumberField", async () => {
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
@@ -15,5 +15,5 @@ describe("[data] NumberField", async () => {
expect(transformPersist(field2, 10000)).resolves.toBe(10000); expect(transformPersist(field2, 10000)).resolves.toBe(10000);
}); });
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" }); fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" });
}); });
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { TextField } from "../../../../src/data"; import { TextField } from "../../../../src/data";
import { runBaseFieldTests, transformPersist } from "./inc"; import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
describe("[data] TextField", async () => { describe("[data] TextField", async () => {
test("transformPersist (config)", async () => { test("transformPersist (config)", async () => {
@@ -11,5 +11,5 @@ describe("[data] TextField", async () => {
expect(transformPersist(field, "abc")).resolves.toBe("abc"); expect(transformPersist(field, "abc")).resolves.toBe("abc");
}); });
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" }); fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" });
}); });
+22 -41
View File
@@ -1,5 +1,23 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows"; import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows";
import { Type } from "@sinclair/typebox";
export class StringifyTask<Output extends string> extends Task<
typeof StringifyTask.schema,
Output
> {
type = "stringify";
static override schema = Type.Optional(
Type.Object({
input: Type.Optional(Type.String()),
}),
);
async execute() {
return JSON.stringify(this.params.input) as Output;
}
}
describe("SubFlowTask", async () => { describe("SubFlowTask", async () => {
test("Simple Subflow", async () => { test("Simple Subflow", async () => {
@@ -22,8 +40,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution(); const execution = flow.createExecution();
await execution.start(); await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow"); expect(execution.getResponse()).toEqual("Subflow output: subflow");
}); });
@@ -40,8 +56,8 @@ describe("SubFlowTask", async () => {
loop: true, loop: true,
input: [1, 2, 3], input: [1, 2, 3],
}); });
const task3 = new RenderTask("render2", { const task3 = new StringifyTask("stringify", {
render: `Subflow output: {{ sub.output | join: ", " }}`, input: "{{ sub.output }}",
}); });
const flow = new Flow("test", [task, task2, task3], []); const flow = new Flow("test", [task, task2, task3], []);
@@ -51,41 +67,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution(); const execution = flow.createExecution();
await execution.start(); await execution.start();
console.log("errors", execution.getErrors()); expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"');
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3");
});
test("Simple loop from flow input", async () => {
const subTask = new RenderTask("render", {
render: "run {{ flow.output }}",
});
const subflow = new Flow("subflow", [subTask]);
const task = new LogTask("log");
const task2 = new SubFlowTask("sub", {
flow: subflow,
loop: true,
input: "{{ flow.output | json }}",
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`,
});
const flow = new Flow("test", [task, task2, task3], []);
flow.task(task).asInputFor(task2);
flow.task(task2).asInputFor(task3);
const execution = flow.createExecution();
await execution.start([4, 5, 6]);
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6");
}); });
}); });
+1 -59
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Type } from "../../src/core/utils"; import { Type } from "@sinclair/typebox";
import { Task } from "../../src/flows"; import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task"; import { dynamic } from "../../src/flows/tasks/Task";
@@ -51,62 +51,4 @@ describe("Task", async () => {
expect(result.test).toEqual({ key: "path", value: "1/1" }); expect(result.test).toEqual({ key: "path", value: "1/1" });
}); });
test("resolveParams: with json", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })),
}),
{
test: "{{ some | json }}",
},
{
some: {
key: "path",
value: "1/1",
},
},
);
expect(result.test).toEqual({ key: "path", value: "1/1" });
});
test("resolveParams: with array", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Array(Type.String())),
}),
{
test: '{{ "1,2,3" | split: "," | json }}',
},
);
expect(result.test).toEqual(["1", "2", "3"]);
});
test("resolveParams: boolean", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Boolean()),
}),
{
test: "{{ true }}",
},
);
expect(result.test).toEqual(true);
});
test("resolveParams: float", async () => {
const result = await Task.resolveParams(
Type.Object({
test: dynamic(Type.Number(), Number.parseFloat),
}),
{
test: "{{ 3.14 }}",
},
);
expect(result.test).toEqual(3.14);
});
}); });
+2 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Hono } from "hono"; import { Hono } from "hono";
import { Event, EventManager } from "../../src/core/events"; import { Event, EventManager } from "../../src/core/events";
import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils"; import { parse } from "../../src/core/utils";
import { type Static, type StaticDecode, Type } from "@sinclair/typebox";
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows"; import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task"; import { dynamic } from "../../src/flows/tasks/Task";
+2 -1
View File
@@ -1,7 +1,8 @@
// eslint-disable-next-line import/no-unresolved // eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils"; import { _jsonp, withDisabledConsole } from "../../src/core/utils";
import { type Static, Type } from "@sinclair/typebox";
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows"; import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
/*beforeAll(disableConsoleLog); /*beforeAll(disableConsoleLog);
+1
View File
@@ -78,6 +78,7 @@ export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
export async function enableFetchLogging() { export async function enableFetchLogging() {
const originalFetch = global.fetch; const originalFetch = global.fetch;
// @ts-ignore
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init); const response = await originalFetch(input, init);
const url = input instanceof URL || typeof input === "string" ? input : input.url; const url = input instanceof URL || typeof input === "string" ? input : input.url;
+3 -2
View File
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { createApp, registries } from "../../src";
import { mergeObject, randomString } from "../../src/core/utils"; import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema"; 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"; import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
beforeAll(() => { beforeAll(() => {
@@ -43,8 +43,9 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog); afterAll(enableConsoleLog);
describe("MediaController", () => { describe("MediaController", () => {
test.only("accepts direct", async () => { test("accepts direct", async () => {
const app = await makeApp(); const app = await makeApp();
console.log("app", app);
const file = Bun.file(path); const file = Bun.file(path);
const name = makeName("png"); const name = makeName("png");
+5 -3
View File
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"; 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 * as StorageEvents from "../../src/media/storage/events";
import { StorageAdapter } from "media";
class TestAdapter implements StorageAdapter { class TestAdapter extends StorageAdapter {
files: Record<string, FileBody> = {}; files: Record<string, FileBody> = {};
getName() { getName() {
@@ -61,7 +62,7 @@ describe("Storage", async () => {
test("uploads a file", async () => { test("uploads a file", async () => {
const { const {
meta: { type, size }, 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 }); expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
}); });
@@ -71,6 +72,7 @@ describe("Storage", async () => {
}); });
test("events were fired", async () => { test("events were fired", async () => {
await storage.emgr.executeAsyncs();
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue(); expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue(); expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
// @todo: file access must be tested in controllers // @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,109 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { randomString } from "../../../src/core/utils";
import { StorageS3Adapter } from "../../../src/media";
import { config } from "dotenv";
//import { enableFetchLogging } from "../../helper";
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);
/*
// @todo: preparation to mock s3 calls + replace fast-xml-parser
let cleanup: () => void;
beforeAll(async () => {
cleanup = await enableFetchLogging();
});
afterAll(() => {
cleanup();
}); */
describe.skipIf(ALL_TESTS)("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 as any)).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", () => { describe("media/mime-types", () => {
test("tiny resolves", () => { 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) { for (const [ext, mime] of tests) {
expect(tiny.guess(ext)).toBe(mime); expect(tiny.guess(ext)).toBe(mime);
@@ -69,7 +71,7 @@ describe("media/mime-types", () => {
["application/zip", "zip"], ["application/zip", "zip"],
["text/tab-separated-values", "tsv"], ["text/tab-separated-values", "tsv"],
["application/zip", "zip"], ["application/zip", "zip"],
]; ] as const;
for (const [mime, ext] of tests) { for (const [mime, ext] of tests) {
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext); expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
@@ -86,7 +88,7 @@ describe("media/mime-types", () => {
["image.jpeg", "jpeg"], ["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"], ["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"], ["-473Wx593H-466453554-black-MODEL.avif", "avif"],
]; ] as const;
for (const [filename, ext] of tests) { for (const [filename, ext] of tests) {
expect( expect(
@@ -94,5 +96,10 @@ describe("media/mime-types", () => {
`getRandomizedFilename(): ${filename} should end with ${ext}`, `getRandomizedFilename(): ${filename} should end with ${ext}`,
).toBe(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");
}); });
}); });
+60 -1
View File
@@ -69,7 +69,7 @@ describe("AppAuth", () => {
}, },
body: JSON.stringify({ body: JSON.stringify({
email: "some@body.com", email: "some@body.com",
password: "123456", password: "12345678",
}), }),
}); });
enableConsoleLog(); enableConsoleLog();
@@ -81,6 +81,65 @@ describe("AppAuth", () => {
} }
}); });
test("creates user on register (bcrypt)", async () => {
const auth = new AppAuth(
{
enabled: true,
strategies: {
password: {
type: "password",
config: {
hashing: "bcrypt",
},
},
},
// @ts-ignore
jwt: {
secret: "123456",
},
},
ctx,
);
await auth.build();
await ctx.em.schema().sync({ force: true });
// expect no users, but the query to pass
const res = await ctx.em.repository("users").findMany();
expect(res.data.length).toBe(0);
const app = new AuthController(auth).getController();
{
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: "some@body.com",
password: "12345678",
}),
});
enableConsoleLog();
expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany();
expect(users.length).toBe(1);
expect(users[0]?.email).toBe("some@body.com");
}
{
// check user in database
const rawUser = await ctx.connection.kysely
.selectFrom("users")
.selectAll()
.executeTakeFirstOrThrow();
expect(rawUser.strategy_value).toStartWith("$");
}
});
test("registers auth middleware for bknd routes only", async () => { test("registers auth middleware for bknd routes only", async () => {
const app = createApp({ const app = createApp({
initialConfig: { initialConfig: {
+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 { parse } from "../../src/core/utils";
import { fieldsSchema } from "../../src/data/data-schema"; import { fieldsSchema } from "../../src/data/data-schema";
import { AppData } from "../../src/modules"; import { AppData, type ModuleBuildContext } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite"; import { makeCtx, moduleTestSuite } from "./module-test-suite";
import * as proto from "data/prototype";
describe("AppData", () => { describe("AppData", () => {
moduleTestSuite(AppData); moduleTestSuite(AppData);
let ctx: ModuleBuildContext;
beforeEach(() => {
ctx = makeCtx();
});
test("field config construction", () => { test("field config construction", () => {
expect(parse(fieldsSchema, { type: "text" })).toBeDefined(); 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 { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src"; import { createApp, registries } from "../../src";
import { em, entity, text } from "../../src/data"; 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 { AppMedia } from "../../src/modules";
import { moduleTestSuite } from "./module-test-suite"; import { moduleTestSuite } from "./module-test-suite";
+4 -3
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { type TSchema, Type, stripMark } from "../../src/core/utils"; import { stripMark } from "../../src/core/utils";
import { type TSchema, Type } from "@sinclair/typebox";
import { EntityManager, em, entity, index, text } from "../../src/data"; import { EntityManager, em, entity, index, text } from "../../src/data";
import { DummyConnection } from "../../src/data/connection/DummyConnection"; import { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module"; import { Module } from "../../src/modules/Module";
@@ -9,10 +10,10 @@ function createModule<Schema extends TSchema>(schema: Schema) {
getSchema() { getSchema() {
return schema; return schema;
} }
toJSON() { override toJSON() {
return this.config; return this.config;
} }
useForceParse() { override useForceParse() {
return true; return true;
} }
} }
+132 -5
View File
@@ -1,10 +1,13 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils"; import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
import { entity, text } from "../../src/data"; import { Type } from "@sinclair/typebox";
import { Module } from "../../src/modules/Module"; import { Connection, entity, text } from "data";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager"; import { Module } from "modules/Module";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";
describe("ModuleManager", async () => { describe("ModuleManager", async () => {
test("s1: no config, no build", async () => { test("s1: no config, no build", async () => {
@@ -380,4 +383,128 @@ describe("ModuleManager", async () => {
expect(() => f.default()).toThrow(); 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);
}
});
});
}); });
+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();
});
+23
View File
@@ -0,0 +1,23 @@
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
import { formatNumber } from "core/utils";
const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"],
target: "node",
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
for (const output of result.outputs) {
const size_ = await output.text();
console.info(
c.cyan(formatNumber.fileSize(size_.length)),
c.dim(output.path.replace(import.meta.dir + "/", "")),
);
}
+25 -10
View File
@@ -1,5 +1,6 @@
import { $ } from "bun"; import { $ } from "bun";
import * as tsup from "tsup"; import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2); const args = process.argv.slice(2);
const watch = args.includes("--watch"); const watch = args.includes("--watch");
@@ -8,8 +9,13 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap"); const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean"); const clean = args.includes("--clean");
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) { if (clean) {
console.log("Cleaning dist (w/o static)"); console.info("Cleaning dist (w/o static)");
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`; await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
} }
@@ -21,11 +27,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], { Bun.spawn(["bun", "build:types"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.log("Types built"); console.info("Types built");
Bun.spawn(["bun", "tsc-alias"], { Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit", stdout: "inherit",
onExit: () => { onExit: () => {
console.log("Types aliased"); console.info("Types aliased");
types_running = false; types_running = false;
}, },
}); });
@@ -47,14 +53,14 @@ if (types && !watch) {
} }
function banner(title: string) { function banner(title: string) {
console.log(""); console.info("");
console.log("=".repeat(40)); console.info("=".repeat(40));
console.log(title.toUpperCase()); console.info(title.toUpperCase());
console.log("-".repeat(40)); console.info("-".repeat(40));
} }
// collection of always-external packages // collection of always-external packages
const external = ["bun:test", "@libsql/client"] as const; const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
/** /**
* Building backend and general API * Building backend and general API
@@ -65,7 +71,14 @@ async function buildApi() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], define,
entry: [
"src/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/data/index.ts",
"src/media/index.ts",
],
outDir: "dist", outDir: "dist",
external: [...external], external: [...external],
metafile: true, metafile: true,
@@ -95,6 +108,7 @@ async function buildUi() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
define,
external: [ external: [
...external, ...external,
"react", "react",
@@ -154,6 +168,7 @@ async function buildUiElements() {
minify, minify,
sourcemap, sourcemap,
watch, watch,
define,
entry: ["src/ui/elements/index.ts"], entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements", outDir: "dist/ui/elements",
external: [ external: [
@@ -205,7 +220,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
}, },
...overrides, ...overrides,
define: { define: {
__isDev: "0", ...define,
...overrides.define, ...overrides.define,
}, },
external: [ external: [
+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");
});
+55 -29
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.10.0", "version": "0.13.0",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -14,12 +14,11 @@
"url": "https://github.com/bknd-io/bknd/issues" "url": "https://github.com/bknd-io/bknd/issues"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "BKND_CLI_LOG_LEVEL=debug 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": "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: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 --env PUBLIC_* --minify", "build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun run build.cli.ts",
"build:static": "vite build", "build:static": "vite build",
"watch": "bun run build.ts --types --watch", "watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit", "types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -27,36 +26,49 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias", "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../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" "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", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^4.1.1", "@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9", "@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.14.0", "@hono/swagger-ui": "^0.5.1",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1", "@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1", "@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30", "@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10", "@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4", "@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8", "fast-xml-parser": "^5.0.8",
"hono": "^4.7.4", "hono": "^4.7.4",
"json-schema-form-react": "^0.0.2", "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", "json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.6", "kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3", "radix-ui": "^1.1.3",
"swr": "^2.3.3" "swr": "^2.3.3"
}, },
@@ -70,21 +82,28 @@
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@mantine/modals": "^7.17.1", "@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1", "@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
"@rjsf/core": "5.22.2", "@rjsf/core": "5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@tailwindcss/postcss": "^4.0.12", "@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^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/node": "^22.13.10",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.9",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"jsdom": "^26.0.0",
"jsonv-ts": "^0.0.14-alpha.6",
"kysely-d1": "^0.3.0", "kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
@@ -100,8 +119,10 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.11", "tsc-alias": "^1.8.11",
"tsup": "^8.4.0", "tsup": "^8.4.0",
"tsx": "^4.19.3",
"vite": "^6.2.1", "vite": "^6.2.1",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0" "wouter": "^3.6.0"
}, },
"optionalDependencies": { "optionalDependencies": {
@@ -118,47 +139,52 @@
".": { ".": {
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"require": "./dist/index.cjs" "require": "./dist/index.js"
}, },
"./ui": { "./ui": {
"types": "./dist/types/ui/index.d.ts", "types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js", "import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs" "require": "./dist/ui/index.js"
}, },
"./elements": { "./elements": {
"types": "./dist/types/ui/elements/index.d.ts", "types": "./dist/types/ui/elements/index.d.ts",
"import": "./dist/ui/elements/index.js", "import": "./dist/ui/elements/index.js",
"require": "./dist/ui/elements/index.cjs" "require": "./dist/ui/elements/index.js"
}, },
"./client": { "./client": {
"types": "./dist/types/ui/client/index.d.ts", "types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js", "import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs" "require": "./dist/ui/client/index.js"
}, },
"./data": { "./data": {
"types": "./dist/types/data/index.d.ts", "types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js", "import": "./dist/data/index.js",
"require": "./dist/data/index.cjs" "require": "./dist/data/index.js"
}, },
"./core": { "./core": {
"types": "./dist/types/core/index.d.ts", "types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js", "import": "./dist/core/index.js",
"require": "./dist/core/index.cjs" "require": "./dist/core/index.js"
}, },
"./utils": { "./utils": {
"types": "./dist/types/core/utils/index.d.ts", "types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js", "import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs" "require": "./dist/core/utils/index.js"
}, },
"./cli": { "./cli": {
"types": "./dist/types/cli/index.d.ts", "types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js", "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": { "./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts", "types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs" "require": "./dist/adapter/cloudflare/index.js"
}, },
"./adapter": { "./adapter": {
"types": "./dist/types/adapter/index.d.ts", "types": "./dist/types/adapter/index.d.ts",
@@ -167,37 +193,37 @@
"./adapter/vite": { "./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts", "types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js", "import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs" "require": "./dist/adapter/vite/index.js"
}, },
"./adapter/nextjs": { "./adapter/nextjs": {
"types": "./dist/types/adapter/nextjs/index.d.ts", "types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js", "import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs" "require": "./dist/adapter/nextjs/index.js"
}, },
"./adapter/react-router": { "./adapter/react-router": {
"types": "./dist/types/adapter/react-router/index.d.ts", "types": "./dist/types/adapter/react-router/index.d.ts",
"import": "./dist/adapter/react-router/index.js", "import": "./dist/adapter/react-router/index.js",
"require": "./dist/adapter/react-router/index.cjs" "require": "./dist/adapter/react-router/index.js"
}, },
"./adapter/bun": { "./adapter/bun": {
"types": "./dist/types/adapter/bun/index.d.ts", "types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js", "import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs" "require": "./dist/adapter/bun/index.js"
}, },
"./adapter/node": { "./adapter/node": {
"types": "./dist/types/adapter/node/index.d.ts", "types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js", "import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs" "require": "./dist/adapter/node/index.js"
}, },
"./adapter/astro": { "./adapter/astro": {
"types": "./dist/types/adapter/astro/index.d.ts", "types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js", "import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs" "require": "./dist/adapter/astro/index.js"
}, },
"./adapter/aws": { "./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts", "types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js", "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/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css", "./dist/styles.css": "./dist/ui/styles.css",
+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,
});
+82 -25
View File
@@ -1,13 +1,19 @@
import type { SafeUser } from "auth"; import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi"; import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi"; import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt"; import { decode } from "hono/jwt";
import { MediaApi } from "media/api/MediaApi"; import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi"; import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils"; import { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser; export type TApiUser = SafeUser;
export type ApiFetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
declare global { declare global {
interface Window { interface Window {
__BKND__: { __BKND__: {
@@ -16,14 +22,24 @@ declare global {
} }
} }
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = { export type ApiOptions = {
host?: string; host?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
localStorage?: boolean; storage?: {
fetcher?: typeof fetch; getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
setItem: (key: string, value: string) => void | Promise<void>;
removeItem: (key: string) => void | Promise<void>;
};
onAuthStateChange?: (state: AuthState) => void;
fetcher?: ApiFetcher;
verbose?: boolean; verbose?: boolean;
verified?: boolean; verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & ( } & (
| { | {
token?: string; token?: string;
@@ -56,18 +72,18 @@ export class Api {
this.verified = options.verified === true; this.verified = options.verified === true;
// prefer request if given // prefer request if given
if ("request" in options) { if ("request" in options && options.request) {
this.options.host = options.host ?? new URL(options.request.url).origin; this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers; this.options.headers = options.headers ?? options.request.headers;
this.extractToken(); this.extractToken();
// then check for a token // then check for a token
} else if ("token" in options) { } else if ("token" in options && options.token) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(options.token); this.updateToken(options.token, { trigger: false });
// then check for an user object // then check for an user object
} else if ("user" in options) { } else if ("user" in options && options.user) {
this.token_transport = "none"; this.token_transport = "none";
this.user = options.user; this.user = options.user;
this.verified = options.verified !== false; this.verified = options.verified !== false;
@@ -78,6 +94,10 @@ export class Api {
this.buildApis(); this.buildApis();
} }
get fetcher() {
return this.options.fetcher ?? fetch;
}
get baseUrl() { get baseUrl() {
return this.options.host ?? "http://localhost"; return this.options.host ?? "http://localhost";
} }
@@ -106,18 +126,30 @@ export class Api {
this.updateToken(headerToken); this.updateToken(headerToken);
return; return;
} }
} else if (this.options.localStorage) { } else if (this.storage) {
const token = localStorage.getItem(this.tokenKey); this.storage.getItem(this.tokenKey).then((token) => {
if (token) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(token); this.updateToken(token ? String(token) : undefined);
} });
} }
//console.warn("Couldn't extract token");
} }
updateToken(token?: string, rebuild?: boolean) { private get storage() {
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
}
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
this.token = token; this.token = token;
this.verified = false; this.verified = false;
@@ -127,17 +159,25 @@ export class Api {
this.user = undefined; this.user = undefined;
} }
if (this.options.localStorage) { if (this.storage) {
const key = this.tokenKey; const key = this.tokenKey;
if (token) { if (token) {
localStorage.setItem(key, token); this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} else { } else {
localStorage.removeItem(key); this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
} }
} }
if (rebuild) this.buildApis(); if (opts?.rebuild) this.buildApis();
} }
private markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
@@ -207,15 +247,32 @@ export class Api {
const fetcher = this.options.fetcher; const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher); this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher); this.data = new DataApi(
this.auth = new AuthApi(
{ {
...baseParams, ...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true), ...this.options.data,
},
fetcher,
);
this.auth = new AuthApi(
{
...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
fetcher,
);
this.media = new MediaApi(
{
...baseParams,
...this.options.media,
}, },
fetcher, fetcher,
); );
this.media = new MediaApi(baseParams, fetcher);
} }
} }
+77 -36
View File
@@ -4,9 +4,10 @@ import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
ModuleManager,
type InitialModuleConfigs, type InitialModuleConfigs,
type ModuleBuildContext, type ModuleBuildContext,
ModuleManager, type ModuleConfigs,
type ModuleManagerOptions, type ModuleManagerOptions,
type Modules, type Modules,
} from "modules/ModuleManager"; } from "modules/ModuleManager";
@@ -14,8 +15,9 @@ import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController"; import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController"; import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be there // biome-ignore format: must be here
import { Api, type ApiOptions } from "Api"; import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";
export type AppPlugin = (app: App) => Promise<void> | void; export type AppPlugin = (app: App) => Promise<void> | void;
@@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent {
export class AppFirstBoot extends AppEvent { export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot"; 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 = { export type AppOptions = {
plugins?: AppPlugin[]; plugins?: AppPlugin[];
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>; seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">; manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
}; };
export type CreateAppConfig = { export type CreateAppConfig = {
connection?: connection?:
@@ -53,12 +68,14 @@ export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions; export type LocalApiOptions = Request | ApiOptions;
export class App { export class App {
modules: ModuleManager;
static readonly Events = AppEvents; static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController; adminController?: AdminController;
_id: string = crypto.randomUUID();
private trigger_first_boot = false; private trigger_first_boot = false;
private plugins: AppPlugin[]; private plugins: AppPlugin[];
private _id: string = crypto.randomUUID();
private _building: boolean = false; private _building: boolean = false;
constructor( constructor(
@@ -70,35 +87,9 @@ export class App {
this.modules = new ModuleManager(connection, { this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}), ...(options?.manager ?? {}),
initial: _initialConfig, initial: _initialConfig,
onUpdated: async (key, config) => { onUpdated: this.onUpdated.bind(this),
// if the EventManager was disabled, we assume we shouldn't onFirstBoot: this.onFirstBoot.bind(this),
// respond to events, such as "onUpdated". onServerInit: this.onServerInit.bind(this),
// 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) {}
});
},
}); });
this.modules.ctx().emgr.registerEvents(AppEvents); this.modules.ctx().emgr.registerEvents(AppEvents);
} }
@@ -160,7 +151,7 @@ export class App {
} }
get fetch(): Hono["fetch"] { get fetch(): Hono["fetch"] {
return this.server.fetch; return this.server.fetch as any;
} }
get module() { get module() {
@@ -189,7 +180,10 @@ export class App {
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); 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; return this;
} }
@@ -213,6 +207,53 @@ export class App {
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher }); 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 = {}) { 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, type FrameworkOptions } from "bknd/adapter";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = { type TAstro = {
request: Request; request: Request;
}; };
export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export type Options = { export async function getApp<Env = AstroEnv>(
mode?: "static" | "dynamic"; config: AstroBkndConfig<Env> = {},
} & Omit<ApiOptions, "host"> & { args: Env = {} as Env,
host?: string; opts: FrameworkOptions = {},
}; ) {
return await createFrameworkApp(config, args ?? import.meta.env, opts);
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;
} }
let app: App; export function serve<Env = AstroEnv>(
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) { config: AstroBkndConfig<Env> = {},
return async (args: Context) => { args: Env = {} as Env,
if (!app) { opts?: FrameworkOptions,
app = await createFrameworkApp(config, args); ) {
} return async (fnArgs: TAstro) => {
return app.fetch(args.request); return (await getApp(config, args, opts)).fetch(fnArgs.request);
}; };
} }
+62 -54
View File
@@ -1,68 +1,76 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { handle } from "hono/aws-lambda"; 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 & { type AwsLambdaEnv = object;
assets?: export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
| { RuntimeBkndConfig<Env> & {
mode: "local"; assets?:
root: string; | {
} mode: "local";
| { root: string;
mode: "url"; }
url: string; | {
}; mode: "url";
}; url: string;
};
};
let app: App; export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
export async function createApp({ { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
adminOptions = false, args: Env = {} as Env,
assets, opts?: RuntimeOptions,
...config ): Promise<App> {
}: AwsLambdaBkndConfig = {}) { let additional: Partial<RuntimeBkndConfig> = {
if (!app) { adminOptions,
let additional: Partial<RuntimeBkndConfig> = { };
adminOptions,
};
if (assets?.mode) { if (assets?.mode) {
switch (assets.mode) { switch (assets.mode) {
case "local": case "local":
// @todo: serve static outside app context // @todo: serve static outside app context
additional = { additional = {
adminOptions: adminOptions === false ? undefined : adminOptions, adminOptions: adminOptions === false ? undefined : adminOptions,
serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({ serveStatic: serveStatic({
root: assets.root, root: assets.root,
onFound: (path, c) => { onFound: (path, c) => {
c.res.headers.set("Cache-Control", "public, max-age=31536000"); c.res.headers.set("Cache-Control", "public, max-age=31536000");
}, },
}), }),
}; };
break; break;
case "url": case "url":
additional.adminOptions = { additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}), ...(typeof adminOptions === "object" ? adminOptions : {}),
assets_path: assets.url, assetsPath: assets.url,
}; };
break; break;
default: default:
throw new Error("Invalid assets mode"); 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 = {}) { export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
console.log("serving lambda"); config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => { return async (event) => {
const app = await createApp(config); const app = await createApp(config, args, opts);
return await handle(app.server)(event); 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" /> /// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import type { App } from "bknd"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core"; import { config } from "bknd/core";
import type { ServeOptions } from "bun"; import type { ServeOptions } from "bun";
import { serveStatic } from "hono/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<Env = BunEnv>(
{ distPath, ...config }: BunBkndConfig<Env> = {},
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) { args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
if (!app) { return await createRuntimeApp(
registerLocalMediaAdapter(); {
app = await createRuntimeApp({
...config, ...config,
serveStatic: serveStatic({ root }), serveStatic: serveStatic({ root }),
}); },
} args ?? (process.env as Env),
opts,
return app; );
} }
export function serve({ export function createHandler<Env = BunEnv>(
distPath, config: BunBkndConfig<Env> = {},
connection, args: Env = {} as Env,
initialConfig, opts?: RuntimeOptions,
options, ) {
port = config.server.default_port, return async (req: Request) => {
onBuilt, const app = await createApp(config, args ?? (process.env as Env), opts);
buildConfig, return app.fetch(req);
adminOptions, };
...serveOptions }
}: BunBkndConfig = {}) {
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({ Bun.serve({
...serveOptions, ...serveOptions,
port, port,
fetch: async (request: Request) => { fetch: createHandler(
const app = await createApp({ {
connection, connection,
initialConfig, initialConfig,
options, options,
@@ -49,9 +66,10 @@ export function serve({
buildConfig, buildConfig,
adminOptions, adminOptions,
distPath, distPath,
}); },
return app.fetch(request); args,
}, opts,
),
}); });
console.log(`Server is running on http://localhost:${port}`); 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,
};
+5 -1
View File
@@ -12,7 +12,7 @@ export type D1ConnectionConfig = {
class CustomD1Dialect extends D1Dialect { class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector { override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, { return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV"], excludeTables: ["_cf_KV", "_cf_METADATA"],
}); });
} }
} }
@@ -32,6 +32,10 @@ export class D1Connection extends SqliteConnection {
super(kysely, {}, plugins); super(kysely, {}, plugins);
} }
get client(): D1Database {
return this.config.binding;
}
protected override async batch<Queries extends QB[]>( protected override async batch<Queries extends QB[]>(
queries: [...Queries], queries: [...Queries],
): Promise<{ ): Promise<{
@@ -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,18 @@
/// <reference types="@cloudflare/workers-types" /> /// <reference types="@cloudflare/workers-types" />
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter"; import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import { D1Connection } from "./D1Connection"; import { getFresh } from "./modes/fresh";
import { registerMedia } from "./StorageR2Adapter";
import { getBinding } from "./bindings";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh"; import type { App } from "bknd";
import { $console } from "core";
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & { export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Context<Env>) => { bindings?: (args: Env) => {
kv?: KVNamespace; kv?: KVNamespace;
dobj?: DurableObjectNamespace; dobj?: DurableObjectNamespace;
db?: D1Database; db?: D1Database;
@@ -22,60 +22,28 @@ export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>>
keepAliveSeconds?: number; keepAliveSeconds?: number;
forceHttps?: boolean; forceHttps?: boolean;
manifest?: string; manifest?: string;
setAdminHtml?: boolean;
html?: string;
}; };
export type Context<Env = any> = { export type Context<Env = CloudflareEnv> = {
request: Request; request: Request;
env: Env; env: Env;
ctx: ExecutionContext; ctx: ExecutionContext;
}; };
let media_registered: boolean = false; export function serve<Env extends CloudflareEnv = CloudflareEnv>(
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { config: CloudflareBkndConfig<Env> = {},
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> = {}) {
return { return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) { async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url); const url = new URL(request.url);
if (config.manifest && config.static === "assets") { if (config.manifest && config.static === "assets") {
console.warn("manifest is not useful with static 'assets'"); $console.warn("manifest is not useful with static 'assets'");
} else if (!config.manifest && config.static === "kv") { } else if (!config.manifest && config.static === "kv") {
throw new Error("manifest is required with static 'kv'"); 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 pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(config.manifest); const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) { if (pathname && pathname in assetManifest) {
@@ -99,21 +67,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"; const mode = config.mode ?? "warm";
let app: App;
switch (mode) { switch (mode) {
case "fresh": case "fresh":
return await getFresh(config, context); app = await getFresh(config, context, { force: true });
break;
case "warm": case "warm":
return await getWarm(config, context); app = await getFresh(config, context);
break;
case "cache": case "cache":
return await getCached(config, context); app = await getCached(config, context);
break;
case "durable": case "durable":
return await getDurable(config, context); return await getDurable(config, context);
default: default:
throw new Error(`Unknown mode ${mode}`); throw new Error(`Unknown mode ${mode}`);
} }
return app.fetch(request, env, ctx);
}, },
}; };
} }
+65
View File
@@ -0,0 +1,65 @@
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";
import { $console } from "core";
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"; import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter"; export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh"; export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached"; export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable"; export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig }; export { D1Connection, type D1ConnectionConfig };
+9 -5
View File
@@ -1,8 +1,12 @@
import { App } from "bknd"; import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter"; 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)!; const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app"; const key = config.key ?? "app";
@@ -16,10 +20,11 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
const app = await createRuntimeApp( const app = await createRuntimeApp(
{ {
...makeCfConfig(config, { env, ctx, ...args }), ...makeConfig(config, env),
initialConfig, initialConfig,
onBuilt: async (app) => { 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); await kv.delete(key);
return c.json({ message: "Cache cleared" }); return c.json({ message: "Cache cleared" });
}); });
@@ -35,7 +40,6 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
); );
await config.beforeBuild?.(app); await config.beforeBuild?.(app);
}, },
adminOptions: { html: config.html },
}, },
{ env, ctx, ...args }, { env, ctx, ...args },
); );
+12 -11
View File
@@ -1,15 +1,20 @@
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
import type { App, CreateAppConfig } from "bknd"; import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter"; import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core";
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)!; const { dobj } = config.bindings?.(ctx.env)!;
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings"); if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
const key = config.key ?? "app"; const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) { if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
console.log("onBuilt and beforeBuild are not supported with DurableObject mode"); $console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
} }
const start = performance.now(); const start = performance.now();
@@ -17,13 +22,11 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key); const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp; 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, { const res = await stub.fire(ctx.request, {
config: create_config, config: create_config,
html: config.html,
keepAliveSeconds: config.keepAliveSeconds, keepAliveSeconds: config.keepAliveSeconds,
setAdminHtml: config.setAdminHtml,
}); });
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
@@ -67,7 +70,8 @@ export class DurableBkndApp extends DurableObject {
this.app = await createRuntimeApp({ this.app = await createRuntimeApp({
...config, ...config,
onBuilt: async (app) => { 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 // @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf; const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({ return c.json({
@@ -92,7 +96,6 @@ export class DurableBkndApp extends DurableObject {
this.keepAlive(options.keepAliveSeconds); this.keepAlive(options.keepAliveSeconds);
} }
console.log("id", this.id);
const res = await this.app!.fetch(request); const res = await this.app!.fetch(request);
const headers = new Headers(res.headers); const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString()); headers.set("X-BuildTime", buildtime.toString());
@@ -106,19 +109,17 @@ export class DurableBkndApp extends DurableObject {
} }
async onBuilt(app: App) {} async onBuilt(app: App) {}
async beforeBuild(app: App) {} async beforeBuild(app: App) {}
protected keepAlive(seconds: number) { protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) { if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval); clearInterval(this.interval);
} }
let i = 0; let i = 0;
this.interval = setInterval(() => { this.interval = setInterval(() => {
i += 1; i += 1;
//console.log("keep-alive", i);
if (i === seconds) { if (i === seconds) {
console.log("cleared"); console.log("cleared");
clearInterval(this.interval); clearInterval(this.interval);
+24 -22
View File
@@ -1,27 +1,29 @@
import type { App } from "bknd"; import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { createRuntimeApp } from "bknd/adapter"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; import { makeConfig, registerAsyncsExecutionContext } from "../config";
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
return await createRuntimeApp( 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), ...config,
adminOptions: config.html ? { html: config.html } : undefined, onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
await 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/test";
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,10 @@
import { registries } from "bknd"; import { registries } from "bknd";
import { isDebug } from "bknd/core"; import { isDebug } from "bknd/core";
import { StringEnum, Type } from "bknd/utils"; import { StringEnum } from "bknd/utils";
import type { FileBody, StorageAdapter } from "media/storage/Storage"; import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { guess } from "media/storage/mime-types-tiny"; import { getBindings } from "../bindings";
import { getBindings } from "./bindings"; import * as tb from "@sinclair/typebox";
const { Type } = tb;
export function makeSchema(bindings: string[] = []) { export function makeSchema(bindings: string[] = []) {
return Type.Object( return Type.Object(
@@ -47,8 +48,10 @@ export function registerMedia(env: Record<string, any>) {
* Adapter for R2 storage * Adapter for R2 storage
* @todo: add tests (bun tests won't work, need node native tests) * @todo: add tests (bun tests won't work, need node native tests)
*/ */
export class StorageR2Adapter implements StorageAdapter { export class StorageR2Adapter extends StorageAdapter {
constructor(private readonly bucket: R2Bucket) {} constructor(private readonly bucket: R2Bucket) {
super();
}
getName(): string { getName(): string {
return "r2"; return "r2";
@@ -121,12 +124,10 @@ export class StorageR2Adapter implements StorageAdapter {
} }
} }
//console.log("response headers:before", headersToObject(responseHeaders));
this.writeHttpMetadata(responseHeaders, object); this.writeHttpMetadata(responseHeaders, object);
responseHeaders.set("etag", object.httpEtag); responseHeaders.set("etag", object.httpEtag);
responseHeaders.set("Content-Length", String(object.size)); responseHeaders.set("Content-Length", String(object.size));
responseHeaders.set("Last-Modified", object.uploaded.toUTCString()); responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
//console.log("response headers:after", headersToObject(responseHeaders));
return new Response(object.body, { return new Response(object.body, {
status: object.range ? 206 : 200, status: object.range ? 206 : 200,
+80 -43
View File
@@ -12,76 +12,113 @@ export type BkndConfig<Args = any> = CreateAppConfig & {
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>; 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> & { export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string; distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
adminOptions?: AdminControllerOptions | false; 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 = {}; let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) { const { app, ...rest } = config;
if (typeof config.app === "function") { if (app) {
if (typeof app === "function") {
if (!args) { if (!args) {
throw new Error("args is required when config.app is a function"); throw new Error("args is required when config.app is a function");
} }
additionalConfig = config.app(args); additionalConfig = app(args);
} else { } else {
additionalConfig = config.app; additionalConfig = app;
} }
} }
return { ...config, ...additionalConfig }; return { ...rest, ...additionalConfig };
} }
export async function createFrameworkApp<Args = any>( // a map that contains all apps by id
config: FrameworkBkndConfig, const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config,
args?: Args, args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> { ): 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.emgr.onEvent(
App.Events.AppBuiltEvent, App.Events.AppBuiltEvent,
async () => { 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); await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
}, },
"sync", "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; 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,
});
});
+16 -32
View File
@@ -1,36 +1,18 @@
import type { App } from "bknd"; import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { isNode } from "bknd/utils";
import { isNode } from "core/utils"; import type { NextApiRequest } from "next";
export type NextjsBkndConfig = FrameworkBkndConfig & { type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] }; cleanRequest?: { searchParams?: string[] };
}; };
type NextjsContext = { export async function getApp<Env = NextjsEnv>(
env: Record<string, string | undefined>; config: NextjsBkndConfig<Env>,
}; args: Env = {} as Env,
opts?: FrameworkOptions,
let app: App;
let building: boolean = false;
export async function getApp<Args extends NextjsContext = NextjsContext>(
config: NextjsBkndConfig,
args?: Args,
) { ) {
if (building) { return await createFrameworkApp(config, args ?? (process.env as Env), opts);
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;
} }
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) { function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -56,11 +38,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) => { return async (req: Request) => {
if (!app) { const app = await getApp(config, args, opts);
app = await getApp(config, { env: process.env ?? {} });
}
const request = getCleanRequest(req, cleanRequest); const request = getCleanRequest(req, cleanRequest);
return app.fetch(request); return app.fetch(request);
}; };
+11 -5
View File
@@ -1,12 +1,18 @@
import { registries } from "bknd"; import { registries } from "bknd";
import { import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
type LocalAdapterConfig,
StorageLocalAdapter,
} from "../../media/storage/adapters/StorageLocalAdapter";
export * from "./node.adapter"; export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig }; export { StorageLocalAdapter, type LocalAdapterConfig };
let registered = false;
export function registerLocalMediaAdapter() { 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/test";
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,
});
});
+38 -24
View File
@@ -2,11 +2,12 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index"; import { registerLocalMediaAdapter } from "adapter/node/index";
import type { App } from "bknd"; import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config } from "bknd/core"; import { config as $config } from "bknd/core";
import { $console } from "core";
export type NodeBkndConfig = RuntimeBkndConfig & { type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
port?: number; port?: number;
hostname?: string; hostname?: string;
listener?: Parameters<typeof honoServe>[1]; listener?: Parameters<typeof honoServe>[1];
@@ -14,14 +15,11 @@ export type NodeBkndConfig = RuntimeBkndConfig & {
relativeDistPath?: string; relativeDistPath?: string;
}; };
export function serve({ export async function createApp<Env = NodeEnv>(
distPath, { distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
relativeDistPath, args: Env = {} as Env,
port = $config.server.default_port, opts?: RuntimeOptions,
hostname, ) {
listener,
...config
}: NodeBkndConfig = {}) {
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
@@ -30,26 +28,42 @@ export function serve({
console.warn("relativeDistPath is deprecated, please use distPath instead"); 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( honoServe(
{ {
port, port,
hostname, hostname,
fetch: async (req: Request) => { fetch: createHandler(config, args, opts),
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
...config,
serveStatic: serveStatic({ root }),
});
}
return app.fetch(req);
},
}, },
(connInfo) => { (connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`); $console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo); listener?.(connInfo);
}, },
); );
@@ -0,0 +1,18 @@
import { describe } from "node:test";
import { nodeTestRunner } from "adapter/node/test";
import { StorageLocalAdapter } 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,23 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, isFile, parse } from "core/utils"; import { type Static, isFile, parse } from "bknd/utils";
import type { import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
FileBody, import { StorageAdapter, guessMimeType as guess } from "bknd/media";
FileListObject, import * as tb from "@sinclair/typebox";
FileMeta, const { Type } = tb;
FileUploadPayload,
StorageAdapter,
} from "../../Storage";
import { guess } from "../../mime-types-tiny";
export const localAdapterConfig = Type.Object( export const localAdapterConfig = Type.Object(
{ {
path: Type.String({ default: "./" }), 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 type LocalAdapterConfig = Static<typeof localAdapterConfig>;
export class StorageLocalAdapter implements StorageAdapter { export class StorageLocalAdapter extends StorageAdapter {
private config: LocalAdapterConfig; private config: LocalAdapterConfig;
constructor(config: any) { constructor(config: Partial<LocalAdapterConfig> = {}) {
super();
this.config = parse(localAdapterConfig, config); 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),
}),
}),
};
@@ -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 }),
});
});
@@ -1,39 +1,26 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterContext = { type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = {
request: Request; request: Request;
}; };
export type ReactRouterBkndConfig<Args = ReactRouterContext> = FrameworkBkndConfig<Args>; export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<Env>;
let app: App; export async function getApp<Env = ReactRouterEnv>(
let building: boolean = false; config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env,
export async function getApp<Args extends ReactRouterContext = ReactRouterContext>( opts?: FrameworkOptions,
config: ReactRouterBkndConfig<Args>,
args?: Args,
) { ) {
if (building) { return await createFrameworkApp(config, args ?? process.env, opts);
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 ReactRouterContext = ReactRouterContext>( export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Args> = {}, config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) { ) {
return async (args: Args) => { return async (fnArgs: ReactRouterFunctionArgs) => {
app = await getApp(config, args); return (await getApp(config, args, opts)).fetch(fnArgs.request);
return app.fetch(args.request);
}; };
} }
+32 -38
View File
@@ -1,18 +1,24 @@
import { serveStatic } from "@hono/node-server/serve-static"; 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 { 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 { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & { export type ViteEnv = NodeJS.ProcessEnv;
mode?: "cached" | "fresh"; export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
setAdminHtml?: boolean;
forceDev?: boolean | { mainPath: string };
html?: string;
};
export function addViteScript(html: string, addBkndContext: boolean = true) { export function addViteScript(
html: string,
addBkndContext: boolean = true,
) {
return html.replace( return html.replace(
"</head>", "</head>",
`<script type="module"> `<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(); registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
adminOptions: adminOptions: config.adminOptions ?? {
config.setAdminHtml === false forceDev: {
? undefined mainPath: "/src/main.tsx",
: { },
html: config.html, },
forceDev: config.forceDev ?? {
mainPath: "/src/main.tsx",
},
},
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })], serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
}, },
env, env,
opts,
); );
} }
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) { export function serve<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
return { return {
async fetch(request: Request, env: any, ctx: ExecutionContext) { 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); 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) { export function devServer(options: DevServerOptions) {
return honoViteDevServer({ return honoViteDevServer({
...devServerConfig, ...devServerConfig,
+15 -136
View File
@@ -1,29 +1,23 @@
import { import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy,
} from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies"; import type { PasswordStrategy } from "auth/authenticate/strategies";
import { $console, type DB, Exception, type PrimaryFieldType } from "core"; import { $console, type DB } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils"; import { secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data"; import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, text } from "data/prototype"; import { em, entity, enumm, type FieldSchema, text } from "data/prototype";
import { pick } from "lodash-es";
import { Module } from "modules/Module"; import { Module } from "modules/Module";
import { AuthController } from "./api/AuthController"; import { AuthController } from "./api/AuthController";
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema"; import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
import { AppUserPool } from "auth/AppUserPool";
import type { AppEntity } from "core/config";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>; export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "core" { declare module "core" {
interface Users extends AppEntity, UserFieldSchema {}
interface DB { interface DB {
users: { id: PrimaryFieldType } & UserFieldSchema; users: Users;
} }
} }
type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any }; export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> { export class AppAuth extends Module<typeof authConfigSchema> {
@@ -31,12 +25,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
cache: Record<string, any> = {}; cache: Record<string, any> = {};
_controller!: AuthController; _controller!: AuthController;
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) { override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) {
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default; const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
if (!from.enabled && to.enabled) { if (!from.enabled && to.enabled) {
if (to.jwt.secret === defaultSecret) { if (to.jwt.secret === defaultSecret) {
console.warn("No JWT secret provided, generating a random one"); $console.warn("No JWT secret provided, generating a random one");
to.jwt.secret = secureRandomString(64); to.jwt.secret = secureRandomString(64);
} }
} }
@@ -80,7 +74,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} }
}); });
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), { this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
jwt: this.config.jwt, jwt: this.config.jwt,
cookie: this.config.cookie, cookie: this.config.cookie,
}); });
@@ -90,7 +84,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
this._controller = new AuthController(this); this._controller = new AuthController(this);
this.ctx.server.route(this.config.basepath, this._controller.getController()); this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(Object.values(AuthPermissions)); this.ctx.guard.registerPermissions(AuthPermissions);
} }
isStrategyEnabled(strategy: Strategy | string) { isStrategyEnabled(strategy: Strategy | string) {
@@ -122,120 +116,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.ctx.em as any; return this.ctx.em as any;
} }
private async resolveUser(
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
): Promise<any> {
if (!this.config.allow_register && action === "register") {
throw new Exception("Registration is not allowed", 403);
}
const fields = this.getUsersEntity()
.getFillableFields("create")
.map((f) => f.name);
const filteredProfile = Object.fromEntries(
Object.entries(profile).filter(([key]) => fields.includes(key)),
);
switch (action) {
case "login":
return this.login(strategy, identifier, filteredProfile);
case "register":
return this.register(strategy, identifier, filteredProfile);
}
}
private filterUserData(user: any) {
return pick(user, this.config.jwt.fields);
}
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
if (!("email" in profile)) {
throw new Exception("Profile must have email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
this.toggleStrategyValueVisibility(true);
const result = await this.em
.repo(users as unknown as "users")
.findOne({ email: profile.email! });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
throw new Exception("User not found", 404);
}
// compare strategy and identifier
if (result.data.strategy !== strategy.getName()) {
//console.log("!!! User registered with different strategy");
throw new Exception("User registered with different strategy");
}
if (result.data.strategy_value !== identifier) {
throw new Exception("Invalid credentials");
}
return this.filterUserData(result.data);
}
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
if (!("email" in profile)) {
throw new Exception("Profile must have an email");
}
if (typeof identifier !== "string" || identifier.length === 0) {
throw new Exception("Identifier must be a string");
}
const users = this.getUsersEntity();
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
if (data) {
throw new Exception("User already exists");
}
const payload: any = {
...profile,
strategy: strategy.getName(),
strategy_value: identifier,
};
const mutator = this.em.mutator(users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new Error("Could not create user");
}
return this.filterUserData(createResult.data);
}
private toggleStrategyValueVisibility(visible: boolean) {
const toggle = (name: string, visible: boolean) => {
const field = this.getUsersEntity().field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
// @todo: think about a PasswordField that automatically hashes on save?
}
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> { getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
const entity_name = this.config.entity_name; const entity_name = this.config.entity_name;
if (forceCreate || !this.em.hasEntity(entity_name)) { if (forceCreate || !this.em.hasEntity(entity_name)) {
@@ -288,7 +168,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
throw new Error("Cannot create user, auth not enabled"); throw new Error("Cannot create user, auth not enabled");
} }
const strategy = "password"; const strategy = "password" as const;
const pw = this.authenticator.strategy(strategy) as PasswordStrategy; const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(password); const strategy_value = await pw.hash(password);
const mutator = this.em.mutator(this.config.entity_name as "users"); const mutator = this.em.mutator(this.config.entity_name as "users");
@@ -315,8 +195,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
...this.authenticator.toJSON(secrets), ...this.authenticator.toJSON(secrets),
strategies: transformObject(strategies, (strategy) => ({ strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy), enabled: this.isStrategyEnabled(strategy),
type: strategy.getType(), ...strategy.toJSON(secrets),
config: strategy.toJSON(secrets),
})), })),
}; };
} }
+83
View File
@@ -0,0 +1,83 @@
import { AppAuth } from "auth/AppAuth";
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
import { $console } from "core";
import { pick } from "lodash-es";
import {
InvalidConditionsException,
UnableToCreateUserException,
UserNotFoundException,
} from "auth/errors";
export class AppUserPool implements UserPool {
constructor(private appAuth: AppAuth) {}
get em() {
return this.appAuth.em;
}
get users() {
return this.appAuth.getUsersEntity();
}
async findBy(strategy: string, prop: keyof SafeUser, value: any) {
$console.debug("[AppUserPool:findBy]", { strategy, prop, value });
this.toggleStrategyValueVisibility(true);
const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy });
this.toggleStrategyValueVisibility(false);
if (!result.data) {
$console.debug("[AppUserPool]: User not found");
throw new UserNotFoundException();
}
return result.data;
}
async create(strategy: string, payload: CreateUser & Partial<Omit<User, "id">>) {
$console.debug("[AppUserPool:create]", { strategy, payload });
if (!("strategy_value" in payload)) {
throw new InvalidConditionsException("Profile must have a strategy_value value");
}
const fields = this.users.getSelect(undefined, "create");
const safeProfile = pick(payload, fields) as any;
const createPayload: Omit<User, "id"> = {
...safeProfile,
strategy,
};
const mutator = this.em.mutator(this.users);
mutator.__unstable_toggleSystemEntityCreation(false);
this.toggleStrategyValueVisibility(true);
const createResult = await mutator.insertOne(createPayload);
mutator.__unstable_toggleSystemEntityCreation(true);
this.toggleStrategyValueVisibility(false);
if (!createResult.data) {
throw new UnableToCreateUserException();
}
$console.debug("[AppUserPool]: User created", createResult.data);
return createResult.data;
}
private toggleStrategyValueVisibility(visible: boolean) {
const toggle = (name: string, visible: boolean) => {
const field = this.users.field(name)!;
if (visible) {
field.config.hidden = false;
field.config.fillable = true;
} else {
// reset to normal
const template = AppAuth.usersFields.strategy_value.config;
field.config.hidden = template.hidden;
field.config.fillable = template.fillable;
}
};
toggle("strategy_value", visible);
toggle("strategy", visible);
// @todo: think about a PasswordField that automatically hashes on save?
}
}
+8 -4
View File
@@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi"; import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & { export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token: string) => void | Promise<void>; onTokenUpdate?: (token?: string) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
}; };
export class AuthApi extends ModuleApi<AuthApiOptions> { export class AuthApi extends ModuleApi<AuthApiOptions> {
protected override getDefaultOptions(): Partial<AuthApiOptions> { protected override getDefaultOptions(): Partial<AuthApiOptions> {
return { return {
basepath: "/api/auth", basepath: "/api/auth",
credentials: "include",
}; };
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, { const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include", credentials: this.options.credentials,
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, { const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include", credentials: this.options.credentials,
}); });
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]); return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
} }
async logout() {} async logout() {
await this.options.onTokenUpdate?.(undefined);
}
} }
+65 -37
View File
@@ -1,9 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth"; import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core"; import { TypeInvalidError, parse, transformObject } from "core/utils";
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data"; import { DataPermissions } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller"; import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s } from "core/object/schema";
export type AuthActionResponse = { export type AuthActionResponse = {
success: boolean; success: boolean;
@@ -12,10 +12,6 @@ export type AuthActionResponse = {
errors?: any; errors?: any;
}; };
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller { export class AuthController extends Controller {
constructor(private auth: AppAuth) { constructor(private auth: AppAuth) {
super(); super();
@@ -54,6 +50,10 @@ export class AuthController extends Controller {
hono.post( hono.post(
"/create", "/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]), permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => { async (c) => {
try { try {
const body = await this.auth.authenticator.getBody(c); const body = await this.auth.authenticator.getBody(c);
@@ -91,9 +91,16 @@ export class AuthController extends Controller {
} }
}, },
); );
hono.get("create/schema.json", async (c) => { hono.get(
return c.json(create.schema); "create/schema.json",
}); describeRoute({
summary: "Get the schema for creating a user",
tags: ["auth"],
}),
async (c) => {
return c.json(create.schema);
},
);
} }
mainHono.route(`/${name}/actions`, hono); mainHono.route(`/${name}/actions`, hono);
@@ -102,42 +109,54 @@ export class AuthController extends Controller {
override getController() { override getController() {
const { auth } = this.middlewares; const { auth } = this.middlewares;
const hono = this.create(); const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) { hono.get(
if (!this.auth.isStrategyEnabled(strategy)) continue; "/me",
describeRoute({
summary: "Get the current user",
tags: ["auth"],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
}
hono.route(`/${name}`, strategy.getController(this.auth.authenticator)); return c.json({ user: null }, 403);
this.registerStrategyActions(strategy, hono); },
} );
hono.get("/me", auth(), async (c) => { hono.get(
const claims = c.get("auth")?.user; "/logout",
if (claims) { describeRoute({
const { data: user } = await this.userRepo.findId(claims.id); summary: "Logout the current user",
return c.json({ user }); tags: ["auth"],
} }),
auth(),
async (c) => {
await this.auth.authenticator.logout(c);
if (this.auth.authenticator.isJsonRequest(c)) {
return c.json({ ok: true });
}
return c.json({ user: null }, 403); const referer = c.req.header("referer");
}); if (referer) {
return c.redirect(referer);
}
hono.get("/logout", auth(), async (c) => { return c.redirect("/");
await this.auth.authenticator.logout(c); },
if (this.auth.authenticator.isJsonRequest(c)) { );
return c.json({ ok: true });
}
const referer = c.req.header("referer");
if (referer) {
return c.redirect(referer);
}
return c.redirect("/");
});
hono.get( hono.get(
"/strategies", "/strategies",
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })), describeRoute({
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => { async (c) => {
const { include_disabled } = c.req.valid("query"); const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false); const { strategies, basepath } = this.auth.toJSON(false);
@@ -155,6 +174,15 @@ export class AuthController extends Controller {
}, },
); );
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return hono.all("*", (c) => c.notFound()); return hono.all("*", (c) => c.notFound());
} }
} }

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