Compare commits

...

106 Commits

Author SHA1 Message Date
dswbx fc282b795e Merge pull request #189 from bknd-io/release/0.15
Release 0.15
2025-07-05 10:24:03 +02:00
dswbx bc65f5f311 Merge remote-tracking branch 'origin/main' into release/0.15 2025-07-05 10:21:25 +02:00
dswbx 5355f2593e readme: add filesystem 2025-07-05 09:43:22 +02:00
dswbx e939debba5 auth: add delete auth after flash in case only one is picked up 2025-07-05 09:43:14 +02:00
dswbx b35ee36fb1 admin: fix back behavior to not rely on history object 2025-07-05 09:42:53 +02:00
dswbx 109c72e84f update readme and docs on dbs 2025-07-03 15:31:14 +02:00
dswbx 7e0b02a125 Merge pull request #194 from stormbyte/main
Update docker.mdx
2025-07-03 15:16:06 +02:00
dswbx d4076803c6 docs: move file name to block, slightly reformulate docker compose instructions 2025-07-03 15:15:18 +02:00
dswbx a5959acb34 fix admin's assetPath for remote static assets 2025-07-03 15:06:21 +02:00
N.A. Walhof a4ddf780ce Finish the sentences 2025-07-03 15:05:49 +02:00
dswbx 2f684765de libsql: update typings to allow minimal client fns (execute, batch) 2025-07-03 14:11:58 +02:00
dswbx 22b54862e8 admin: fix theme flash 2025-07-02 18:14:35 +02:00
dswbx 45138c25f0 refactor auth/media entities to separate files, suppress node:sqlite warning 2025-07-02 16:36:06 +02:00
dswbx 80034b9b0a fix plugins imports 2025-07-02 16:13:22 +02:00
dswbx e6ee75c712 update edge sqlite import (libsql) 2025-07-02 16:08:33 +02:00
dswbx 56287eb05e fixing sqlite imports, clean up bun and cf examples 2025-07-02 15:46:57 +02:00
dswbx 144f35ec66 Merge pull request #195 from bknd-io/fix/cloudflare-media-range
fix cloudflare r2 adapter range requests
2025-07-02 14:10:43 +02:00
dswbx d1378c6c51 fix cloudflare r2 adapter range requests 2025-07-02 14:07:26 +02:00
dswbx 4e10b36d0d Merge pull request #193 from bknd-io/feat/refactor-sqlites
feat/refactor-sqlites
2025-07-02 14:05:58 +02:00
dswbx 70737c04cd refactor console imports, added config update event 2025-07-02 14:02:50 +02:00
dswbx d41fd5541f rewrite libsql and cloudflare sqlite's to use the generic adapter 2025-07-02 14:02:33 +02:00
stormbyte e76e3531d7 Update docker.mdx
Add docker compose self build image
2025-06-26 15:43:51 +02:00
dswbx a9f3a582eb fix tests and imports 2025-06-25 09:42:25 +02:00
dswbx 57ae2f333c inlined libsql dialect, rewrote d1 to use generic sqlite 2025-06-25 09:35:47 +02:00
dswbx b2086c4da7 fix: plugin schema reconciliation 2025-06-18 10:31:40 +02:00
dswbx 344d729320 Merge pull request #191 from bknd-io/feat/admin-add-options-for-static
admin: add options such as logo return path when served static
2025-06-18 08:15:45 +02:00
dswbx c86f4c12b7 admin: add options such as logo return path when served static 2025-06-18 07:56:58 +02:00
dswbx aaa97ed113 finalize initial app resources/drivers 2025-06-17 19:51:12 +02:00
dswbx 69c8aec6fb Merge pull request #190 from bknd-io/feat/app-resources
init app resources
2025-06-17 19:49:09 +02:00
dswbx f5ceffd80e fix tests 2025-06-17 13:34:31 +02:00
dswbx a9f367aa2c updated build to exclude libsql 2025-06-17 13:18:13 +02:00
dswbx b87696a0db init app resources 2025-06-14 16:59:03 +02:00
dswbx 3338804c34 simplify and export generic sqlite functions 2025-06-14 16:58:38 +02:00
dswbx 6b3ac9e6e2 fix toDriver mutation convertion not respecting default values, react re-renders on navigation, mutator result logging 2025-06-14 08:06:05 +02:00
dswbx 8e1ecfcfe3 change app plugins to be a map to prevent duplicates and provide easier access 2025-06-14 07:22:44 +02:00
dswbx 6e2596fb77 fixing tests 2025-06-14 07:08:46 +02:00
dswbx 2ada4e9f20 various fixes: refactored imports, introduced fromDriver/toDriver to improve compat 2025-06-13 21:15:29 +02:00
dswbx cc038a0a9a Merge pull request #187 from bknd-io/feat/plugin-improvements
improved app plugins
2025-06-13 17:29:42 +02:00
dswbx af6d1960b9 fix tests 2025-06-13 17:27:58 +02:00
dswbx 96ae8f9d63 Merge remote-tracking branch 'origin/release/0.15' into feat/plugin-improvements
# Conflicts:
#	app/package.json
#	app/src/App.ts
2025-06-13 17:24:54 +02:00
dswbx c09d1d53b3 Merge pull request #186 from bknd-io/feat/unify-connections
feat/unify-connections
2025-06-13 17:20:05 +02:00
dswbx 2239333f08 updated docs on databases 2025-06-13 17:18:29 +02:00
dswbx 0710b5bf1f Merge pull request #188 from bknd-io/feat/generic-sqlite
env aware sqlite (node, bun)
2025-06-13 16:00:08 +02:00
dswbx 03c4c41d2d cli now uses adapter logic, require node v22 2025-06-13 15:58:06 +02:00
dswbx 85d8542a2e test with node setup pre step 2025-06-13 14:44:55 +02:00
dswbx f8d2a9090e fixing tests, move node tests to vitest 2025-06-13 14:38:30 +02:00
dswbx bbb7bfb7a1 feat: adding env aware endpoint to obtain sqlite connection, remove libsql hard dependency 2025-06-13 11:09:47 +02:00
dswbx 28e277afe1 updated cf image optimization plugin 2025-06-13 08:30:54 +02:00
dswbx 8517c9b90b added a few initial plugins 2025-06-12 19:58:18 +02:00
dswbx fe5ccd4206 refactor and move cloudflare image transformation plugin 2025-06-12 17:00:06 +02:00
dswbx 8b4b63b3cd feat: improved abilities of plugins, moved schema fns to ctx 2025-06-12 15:29:53 +02:00
dswbx 9c4aac8843 docs: simplified custom postgres description 2025-06-12 10:42:49 +02:00
dswbx d5bb6ffa61 fix adapters, handle entity enum more gracefully 2025-06-12 10:24:50 +02:00
dswbx fc513bb413 Merge pull request #184 from bknd-io/release/0.14
Release 0.14
2025-06-12 09:52:01 +02:00
dswbx 4162b9878a fix admin controller to only serve if defined, and only from specified endpoints 2025-06-12 09:45:14 +02:00
dswbx c75f8d0937 reduce schema manager query log to debug, fix useSearch 2025-06-12 09:23:31 +02:00
dswbx 046c1d21b1 fixed tests 2025-06-12 09:12:40 +02:00
dswbx 6c2e579596 connection: rewrote query execution, batching, added generic sqlite, added node/bun sqlite, aligned repo/mutator results 2025-06-12 09:02:18 +02:00
dswbx 88419548c7 admin: fix useSearch 2025-06-10 08:38:10 +02:00
dswbx 12c955155d bump rc 2025-06-09 20:27:02 +02:00
dswbx 15af9f5261 fix use-search 2025-06-09 20:25:57 +02:00
dswbx 39efb911e9 admin header: added links to docs and version 2025-06-09 07:29:20 +02:00
dswbx 4729203d47 fix useSearch and admin path registration 2025-06-09 07:21:32 +02:00
dswbx 0db052acca Merge pull request #182 from bknd-io/feat/postgres-improvements
postgres: added `pg` and `postgres`, and examples for xata and neon
2025-06-07 11:14:45 +02:00
dswbx 6e08f45857 updated docs on new postgres instructions 2025-06-07 11:11:42 +02:00
dswbx 7bff84d601 updated postgres package build 2025-06-07 10:31:22 +02:00
dswbx e66e05b2b0 postgres: make sure to store id as varchar if uuid 2025-06-07 09:59:50 +02:00
dswbx 5b318ce485 Merge remote-tracking branch 'origin/release/0.14' into feat/postgres-improvements 2025-06-07 09:41:32 +02:00
dswbx 22de16fe17 Merge pull request #183 from bknd-io/feat/uuid
feat: adding initial uuid support
2025-06-07 09:40:43 +02:00
dswbx 5d26673bc6 Merge remote-tracking branch 'origin/release/0.14' into feat/uuid
# Conflicts:
#	app/tsconfig.json
#	bun.lock
2025-06-07 09:38:59 +02:00
dswbx dfa4bc0e33 Merge pull request #181 from bknd-io/feat/cf-sessions
adding d1 session support
2025-06-07 09:32:16 +02:00
dswbx e51b89a18a store session cookie/header name in constants, updated docs 2025-06-07 09:29:56 +02:00
dswbx 63988e0c5f store session cookie/header name in constants, updated docs 2025-06-07 09:29:38 +02:00
dswbx 297fd85a4f Merge branch 'release/0.14' into feat/postgres-improvements 2025-06-07 09:01:49 +02:00
dswbx a82fbe7400 Merge branch 'release/0.14' into feat/cf-sessions 2025-06-07 09:01:05 +02:00
dswbx 74e1e9a03f Merge pull request #179 from bknd-io/fix/hono-cookie-body-disturb
fix: lock hono version because of changed header setting in 4.7.7
2025-06-07 08:59:44 +02:00
dswbx 05a81b5bdc Merge remote-tracking branch 'origin/release/0.14' into fix/hono-cookie-body-disturb 2025-06-07 08:52:54 +02:00
dswbx 9ff49103fb bump rc 2025-06-07 08:50:23 +02:00
dswbx a5b59c004e fix logout by setting the api route as logout target 2025-06-07 08:48:28 +02:00
dswbx a0edcf483b added more tests, updated json schema generation 2025-06-07 08:24:21 +02:00
dswbx 9a18e354cd feat: adding initial uuid support 2025-06-06 20:46:01 +02:00
dswbx 58c7aba1a4 postgres: added pg and postgres, and examples for xata and neon 2025-06-06 16:52:07 +02:00
dswbx f8aa242d2b fix tests 2025-06-06 11:30:27 +02:00
dswbx 3da9570abe reduce worker config type 2025-06-06 11:18:44 +02:00
dswbx 6ee898e606 adding d1 session support 2025-06-06 11:13:35 +02:00
dswbx abbd372ddf Merge pull request #180 from bknd-io/docs/bknd-config-ts
docs: added docs about how to use bknd.config.ts
2025-06-05 17:14:30 +02:00
dswbx 3e77982996 docs: added docs about how to use bknd.config.ts 2025-06-05 17:11:50 +02:00
dswbx 3fbea8ace7 fix: cookie setting by only setting on admin routes 2025-06-03 13:11:18 +02:00
dswbx a41f943b43 fix: cookie setting by only setting on admin routes 2025-06-03 13:06:49 +02:00
dswbx 15a9c549e7 fix: cookie setting by only setting on admin routes 2025-06-03 13:04:03 +02:00
dswbx 50cadbaa8e fix: lock hono version because of changed header setting in 4.7.7 2025-06-03 11:12:47 +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
371 changed files with 20825 additions and 6839 deletions
+6 -1
View File
@@ -12,10 +12,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.x"
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.5"
bun-version: "1.2.14"
- name: Install dependencies
working-directory: ./app
+3 -1
View File
@@ -29,4 +29,6 @@ packages/media/.env
.idea
.vscode
.git_old
docker/tmp
docker/tmp
.debug
.history
+1
View File
@@ -0,0 +1 @@
22
+10 -3
View File
@@ -1,5 +1,4 @@
[![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd)
[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd)
![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png)
@@ -10,22 +9,30 @@
</p>
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
* **Databases**:
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
**For documentation and examples, please visit https://docs.bknd.io.**
> [!WARNING]
> This project requires Node.js 22 or higher (because of `node:sqlite`).
>
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Size
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui)
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker).
## Motivation
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
+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
+130 -12
View File
@@ -1,6 +1,9 @@
import { afterAll, afterEach, describe, expect, test } from "bun:test";
import { App } from "../src";
import { afterEach, describe, test, expect } from "bun:test";
import { App, createApp } from "core/test/utils";
import { getDummyConnection } from "./helper";
import { Hono } from "hono";
import * as proto from "../src/data/prototype";
import { pick } from "lodash-es";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
@@ -10,18 +13,133 @@ describe("App tests", async () => {
const app = new App(dummyConnection);
await app.build();
//expect(await app.data?.em.ping()).toBeTrue();
expect(await app.em.ping()).toBeTrue();
});
/*test.only("what", async () => {
const app = new App(dummyConnection, {
auth: {
enabled: true,
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
initialConfig: {
auth: {
enabled: true,
},
},
options: {
plugins: [
(app) => {
expect(app).toBeDefined();
expect(app).toBeInstanceOf(App);
return {
name: "test",
schema: () => {
called.push("schema");
return proto.em(
{
posts: proto.entity("posts", {
title: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
users: proto.entity("users", {
email_verified: proto.boolean(),
}),
},
(fn, s) => {
fn.relation(s.comments).manyToOne(s.posts);
fn.index(s.posts).on(["title"]);
},
);
},
onBoot: async () => {
called.push("onBoot");
},
beforeBuild: async () => {
called.push("beforeBuild");
},
onBuilt: async () => {
called.push("onBuilt");
},
onServerInit: async (server) => {
called.push("onServerInit");
expect(server).toBeDefined();
expect(server).toBeInstanceOf(Hono);
},
onFirstBoot: async () => {
called.push("onFirstBoot");
},
};
},
],
},
});
await app.module.auth.build();
await app.module.data.build();
console.log(app.em.entities.map((e) => e.name));
console.log(await app.em.schema().getDiff());
});*/
await app.build();
expect(app.em.entities.map((e) => e.name)).toEqual(["users", "posts", "comments"]);
expect(app.em.indices.map((i) => i.name)).toEqual([
"idx_unique_users_email",
"idx_users_strategy",
"idx_users_strategy_value",
"idx_posts_title",
]);
expect(
app.em.relations.all.map((r) => pick(r.toJSON(), ["type", "source", "target"])),
).toEqual([
{
type: "n:1",
source: "comments",
target: "posts",
},
]);
expect(called).toEqual([
"onBoot",
"onServerInit",
"beforeBuild",
"onServerInit",
"schema",
"onFirstBoot",
"onBuilt",
]);
expect(app.plugins.size).toBe(1);
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
});
test.only("drivers", async () => {
const called: string[] = [];
const app = new App(dummyConnection, undefined, {
drivers: {
email: {
send: async (to, subject, body) => {
called.push("email.send");
return {
id: "",
};
},
},
cache: {
get: async (key) => {
called.push("cache.get");
return "";
},
set: async (key, value, ttl) => {
called.push("cache.set");
},
del: async (key) => {
called.push("cache.del");
},
},
},
});
await app.build();
expect(app.drivers.cache).toBeDefined();
expect(app.drivers.email).toBeDefined();
await app.drivers.email.send("", "", "");
await app.drivers.cache.get("");
await app.drivers.cache.set("", "", 0);
await app.drivers.cache.del("");
expect(called).toEqual(["email.send", "cache.get", "cache.set", "cache.del"]);
});
});
+20 -5
View File
@@ -3,21 +3,36 @@ import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { omitKeys } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
it("makes config", () => {
expect(adapter.makeConfig({})).toEqual({});
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).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);
expect(
omitKeys(
adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
});
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
+1 -1
View File
@@ -153,7 +153,7 @@ describe("DataApi", () => {
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
const oneByRes = await oneBy;
expect(oneByRes.data).toEqual({ title: "baz" } as any);
expect(oneByRes.body.meta.count).toEqual(1);
expect(oneByRes.body.meta.items).toEqual(1);
});
it("exists/count", async () => {
+26 -4
View File
@@ -1,8 +1,8 @@
/// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
import { MediaApi } from "../../src/media/api/MediaApi";
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
import { MediaApi } from "media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper";
const mockedBackend = new Hono()
@@ -39,10 +39,28 @@ describe("MediaApi", () => {
// @ts-ignore tests
const api = new MediaApi({
token: "token",
token_transport: "header",
});
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 () => {
const name = "image.png";
const path = `${assetsTmpPath}/${name}`;
@@ -103,8 +121,12 @@ describe("MediaApi", () => {
});
it("should upload file in various ways", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const api = new MediaApi(
{
upload_fetcher: mockedBackend.request,
},
mockedBackend.request,
);
const file = Bun.file(`${assetsPath}/image.png`);
async function matches(req: Promise<any>, filename: string) {
+2 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "../../src/App";
import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
describe("App", () => {
@@ -20,6 +20,7 @@ describe("App", () => {
"guard",
"flags",
"logger",
"helper",
]);
},
},
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
+2 -3
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test";
import type { TObject, TString } from "@sinclair/typebox";
import { Registry } from "../../src/core/registry/Registry";
import { type TSchema, Type } from "../../src/core/utils";
import { type TObject, type TString, Type } from "@sinclair/typebox";
import { Registry } from "core";
type Constructor<T> = new (...args: any[]) => T;
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test";
import { SchemaObject } from "../../../src/core";
import { Type } from "../../../src/core/utils";
import { Type } from "@sinclair/typebox";
describe("SchemaObject", async () => {
test("basic", async () => {
@@ -1,5 +1,5 @@
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", () => {
const q: ObjectQuery = { name: "Michael" };
+27 -76
View File
@@ -7,13 +7,13 @@ import {
type EntityData,
EntityManager,
ManyToOneRelation,
type MutatorResponse,
type RepositoryResponse,
TextField,
} from "../../src/data";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"]));
@@ -21,52 +21,6 @@ afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {});
describe("[data] DataController", async () => {
test("repoResult", async () => {
const em = new EntityManager<any>([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.repoResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
meta: {
total: 0,
count: 0,
items: 0,
},
});
expect(res).toEqual({
meta: {
total: 0,
count: 0,
items: 0,
},
data: [],
});
});
test("mutatorResult", async () => {
const em = new EntityManager([], dummyConnection);
const ctx: any = { em, guard: new Guard() };
const controller = new DataController(ctx, dataConfig);
const res = controller.mutatorResult({
entity: null as any,
data: [] as any,
sql: "",
parameters: [] as any,
result: [] as any,
});
expect(res).toEqual({
data: [],
});
});
describe("getController", async () => {
const users = new Entity("users", [
new TextField("name", { required: true }),
@@ -120,8 +74,7 @@ describe("[data] DataController", async () => {
method: "POST",
body: JSON.stringify(_user),
});
//console.log("res", { _user }, res);
const result = (await res.json()) as MutatorResponse;
const result = (await res.json()) as MutatorResultJSON;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
@@ -135,7 +88,7 @@ describe("[data] DataController", async () => {
method: "POST",
body: JSON.stringify(_post),
});
const result = (await res.json()) as MutatorResponse;
const result = (await res.json()) as MutatorResultJSON;
const { id, ...data } = result.data as any;
expect(res.status).toBe(201);
@@ -146,13 +99,13 @@ describe("[data] DataController", async () => {
test("/:entity (read many)", async () => {
const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResponse;
const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3);
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(3);
expect(data.data.length).toBe(3);
expect(data.data[0].name).toBe("foo");
expect(data.data[0]?.name).toBe("foo");
});
test("/:entity/query (func query)", async () => {
@@ -165,33 +118,32 @@ describe("[data] DataController", async () => {
where: { bio: { $isnull: 1 } },
}),
});
const data = (await res.json()) as RepositoryResponse;
const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1);
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("bar");
expect(data.data[0]?.name).toBe("bar");
});
test("/:entity (read many, paginated)", async () => {
const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse;
const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(3);
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(3);
expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0].name).toBe("baz");
expect(data.data[0]?.name).toBe("baz");
});
test("/:entity/:id (read one)", async () => {
const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResponse<EntityData>;
console.log("data", data);
const data = (await res.json()) as RepositoryResultJSON<EntityData>;
expect(data.meta.total).toBe(3);
expect(data.meta.count).toBe(1);
//expect(data.meta.total).toBe(3);
//expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
});
@@ -201,7 +153,7 @@ describe("[data] DataController", async () => {
method: "PATCH",
body: JSON.stringify({ name: "new name" }),
});
const { data } = (await res.json()) as MutatorResponse;
const { data } = (await res.json()) as MutatorResultJSON;
expect(res.ok).toBe(true);
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
@@ -209,27 +161,26 @@ describe("[data] DataController", async () => {
test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResponse;
console.log("data", data);
const data = (await res.json()) as RepositoryResultJSON;
expect(data.meta.total).toBe(2);
expect(data.meta.count).toBe(1);
//expect(data.meta.total).toBe(2);
//expect(data.meta.count).toBe(1);
expect(data.meta.items).toBe(1);
expect(data.data.length).toBe(1);
expect(data.data[0].content).toBe("post 1");
expect(data.data[0]?.content).toBe("post 1");
});
test("/:entity/:id (delete one)", async () => {
const res = await app.request("/entity/posts/2", {
method: "DELETE",
});
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
const { data } = (await res.json()) as RepositoryResultJSON<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify
const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1);
const data2 = (await res2.json()) as RepositoryResultJSON;
//expect(data2.meta.total).toBe(1);
});
});
});
+17 -10
View File
@@ -34,19 +34,12 @@ describe("some tests", async () => {
test("findId", async () => {
const query = await em.repository(users).findId(1);
/*const { result, total, count, time } = query;
console.log("query", query.result, {
result,
total,
count,
time,
});*/
expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
);
expect(query.parameters).toEqual([1, 1]);
expect(query.result).toEqual([]);
expect(query.data).toBeUndefined();
});
test("findMany", async () => {
@@ -56,7 +49,7 @@ describe("some tests", async () => {
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]);
expect(query.data).toEqual([]);
});
test("findMany with number", async () => {
@@ -66,7 +59,7 @@ describe("some tests", async () => {
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([10, 0]);
expect(query.result).toEqual([]);
expect(query.data).toEqual([]);
});
test("try adding an existing field name", async () => {
@@ -110,4 +103,18 @@ describe("some tests", async () => {
new EntityManager([entity, entity2], connection);
}).toThrow();
});
test("primary uuid", async () => {
const entity = new Entity("users", [
new PrimaryField("id", { format: "uuid" }),
new TextField("username"),
]);
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
const mutator = em.mutator(entity);
const data = await mutator.insertOne({ username: "test" });
expect(data.data.id).toBeDefined();
expect(data.data.id).toBeString();
});
});
+4 -4
View File
@@ -45,7 +45,7 @@ describe("Mutator simple", async () => {
},
});
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]);
expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
});
test("update inserted row", async () => {
@@ -87,7 +87,7 @@ describe("Mutator simple", async () => {
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
const query2 = await em.repository(items).findId(id);
expect(query2.result.length).toBe(0);
expect(query2.data).toBeUndefined();
});
test("validation: insert incomplete row", async () => {
@@ -177,13 +177,13 @@ describe("Mutator simple", async () => {
});
test("insertMany", async () => {
const oldCount = (await em.repo(items).count()).count;
const oldCount = (await em.repo(items).count()).data.count;
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
const { data } = await em.mutator(items).insertMany(inserts);
expect(data.length).toBe(2);
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
const newCount = (await em.repo(items).count()).count;
const newCount = (await em.repo(items).count()).data.count;
expect(newCount).toBe(oldCount + inserts.length);
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
+5 -11
View File
@@ -1,5 +1,6 @@
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 () => {
const entity = new Entity("test", [
@@ -47,14 +48,7 @@ describe("[data] Entity", async () => {
expect(entity.getField("new_field")).toBe(field);
});
// @todo: move this to ClientApp
/*test("serialize and deserialize", async () => {
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);
});*/
test.only("types", async () => {
console.log(entity.toTypes());
});
});
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
em.addRelation(new ManyToOneRelation(posts, users));
expect(em.relations.all.length).toBe(1);
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
expect(em.relationsOf("posts")).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.hasRelations("users")).toBe(true);
expect(em.hasRelations("posts")).toBe(true);
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
+5 -2
View File
@@ -1,4 +1,4 @@
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { EventManager } from "../../../src/core/events";
import {
Entity,
@@ -12,11 +12,14 @@ import {
TextField,
} from "../../../src/data";
import * as proto from "../../../src/data/prototype";
import { getDummyConnection } from "../helper";
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => {
const entity = new Entity("items", [
new TextField("label", { required: true }),
+38 -123
View File
@@ -26,120 +26,6 @@ async function sleep(ms: number) {
}
describe("[Repository]", async () => {
test.skip("bulk", async () => {
//const connection = dummyConnection;
//const connection = getLocalLibsqlConnection();
const credentials = null as any; // @todo: determine what to do here
const connection = new LibsqlConnection(credentials);
const em = new EntityManager([], connection);
/*const emLibsql = new EntityManager([], {
url: connection.url.replace("https", "libsql"),
authToken: connection.authToken,
});*/
const table = "posts";
const client = connection.getClient();
if (!client) {
console.log("Cannot perform test without libsql connection");
return;
}
const conn = em.connection.kysely;
const selectQ = (e: E) => e.selectFrom(table).selectAll().limit(2);
const countQ = (e: E) => e.selectFrom(table).select(e.fn.count("*").as("count"));
async function executeTransaction(em: EntityManager<any>) {
return await em.connection.kysely.transaction().execute(async (e) => {
const res = await selectQ(e).execute();
const count = await countQ(e).execute();
return [res, count];
});
}
async function executeBatch(em: EntityManager<any>) {
const queries = [selectQ(conn), countQ(conn)];
return await em.connection.batchQuery(queries);
}
async function executeSingleKysely(em: EntityManager<any>) {
const res = await selectQ(conn).execute();
const count = await countQ(conn).execute();
return [res, count];
}
async function executeSingleClient(em: EntityManager<any>) {
const q1 = selectQ(conn).compile();
const res = await client.execute({
sql: q1.sql,
args: q1.parameters as any,
});
const q2 = countQ(conn).compile();
const count = await client.execute({
sql: q2.sql,
args: q2.parameters as any,
});
return [res, count];
}
const transaction = await executeTransaction(em);
const batch = await executeBatch(em);
expect(batch).toEqual(transaction as any);
const testperf = false;
if (testperf) {
const times = 5;
const exec = async (
name: string,
fn: (em: EntityManager<any>) => Promise<any>,
em: EntityManager<any>,
) => {
const res = await Perf.execute(() => fn(em), times);
await sleep(1000);
const info = {
name,
total: res.total.toFixed(2),
avg: (res.total / times).toFixed(2),
first: res.marks[0].time.toFixed(2),
last: res.marks[res.marks.length - 1].time.toFixed(2),
};
console.log(info.name, info, res.marks);
return info;
};
const data: any[] = [];
data.push(await exec("transaction.http", executeTransaction, em));
data.push(await exec("bulk.http", executeBatch, em));
data.push(await exec("singleKy.http", executeSingleKysely, em));
data.push(await exec("singleCl.http", executeSingleClient, em));
/*data.push(await exec("transaction.libsql", executeTransaction, emLibsql));
data.push(await exec("bulk.libsql", executeBatch, emLibsql));
data.push(await exec("singleKy.libsql", executeSingleKysely, emLibsql));
data.push(await exec("singleCl.libsql", executeSingleClient, emLibsql));*/
console.table(data);
/**
* ┌───┬────────────────────┬────────┬────────┬────────┬────────┐
* │ │ name │ total │ avg │ first │ last │
* ├───┼────────────────────┼────────┼────────┼────────┼────────┤
* │ 0 │ transaction.http │ 681.29 │ 136.26 │ 136.46 │ 396.09 │
* │ 1 │ bulk.http │ 164.82 │ 32.96 │ 32.95 │ 99.91 │
* │ 2 │ singleKy.http │ 330.01 │ 66.00 │ 65.86 │ 195.41 │
* │ 3 │ singleCl.http │ 326.17 │ 65.23 │ 61.32 │ 198.08 │
* │ 4 │ transaction.libsql │ 856.79 │ 171.36 │ 132.31 │ 595.24 │
* │ 5 │ bulk.libsql │ 180.63 │ 36.13 │ 35.39 │ 107.71 │
* │ 6 │ singleKy.libsql │ 347.11 │ 69.42 │ 65.00 │ 207.14 │
* │ 7 │ singleCl.libsql │ 328.60 │ 65.72 │ 62.19 │ 195.04 │
* └───┴────────────────────┴────────┴────────┴────────┴────────┘
*/
}
});
test("count & exists", async () => {
const items = new Entity("items", [new TextField("label")]);
const em = new EntityManager([items], dummyConnection);
@@ -160,25 +46,44 @@ describe("[Repository]", async () => {
// count all
const res = await em.repository(items).count();
expect(res.sql).toBe('select count(*) as "count" from "items"');
expect(res.count).toBe(3);
expect(res.data.count).toBe(3);
//
{
const res = await em.repository(items).findMany();
expect(res.count).toBe(3);
}
{
const res = await em
.repository(items, {
includeCounts: true,
})
.findMany();
expect(res.count).toBe(3);
}
// count filtered
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } });
const res2 = await em
.repository(items, {
includeCounts: true,
})
.count({ label: { $in: ["a", "b"] } });
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
expect(res2.parameters).toEqual(["a", "b"]);
expect(res2.count).toBe(2);
expect(res2.data.count).toBe(2);
// check exists
const res3 = await em.repository(items).exists({ label: "a" });
expect(res3.exists).toBe(true);
expect(res3.data.exists).toBe(true);
const res4 = await em.repository(items).exists({ label: "d" });
expect(res4.exists).toBe(false);
expect(res4.data.exists).toBe(false);
// for now, allow empty filter
const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true);
expect(res5.data.exists).toBe(true);
});
test("option: silent", async () => {
@@ -191,6 +96,9 @@ describe("[Repository]", async () => {
// should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result
em.repo("items", { silent: true })
.findMany({})
.then((r) => r.data);
expect(
em
.repo("items", { silent: true })
@@ -209,16 +117,16 @@ describe("[Repository]", async () => {
expect(
em
.repo("items")
.repo("items", { includeCounts: true })
.findMany({})
.then((r) => [r.meta.count, r.meta.total]),
.then((r) => [r.count, r.total]),
).resolves.toEqual([0, 0]);
expect(
em
.repo("items", { includeCounts: false })
.findMany({})
.then((r) => [r.meta.count, r.meta.total]),
.then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]);
});
});
@@ -266,5 +174,12 @@ describe("[data] Repository (Events)", async () => {
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
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 { Value, _jsonp } from "../../src/core/utils";
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
import { getDummyConnection } from "./helper";
import { describe, test, expect } from "bun:test";
import { getDummyConnection } from "../helper";
import { type WhereQuery, WhereBuilder } from "data";
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
const result = Value.Decode(querySchema, input);
expect(result).toEqual(expected);
};
describe("data-query-impl", () => {
function qb() {
const c = getDummyConnection();
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 };
}
function qb() {
const c = getDummyConnection();
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", () => {
const tests: [WhereQuery, string, any[]][] = [
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
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" } });
});
});
+8 -8
View File
@@ -89,9 +89,9 @@ describe("[data] WithBuilder", async () => {
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"',
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
);
expect(res2.parameters).toEqual([1, 0]);
expect(res2.parameters).toEqual([1]);
});
test("test with empty join", async () => {
@@ -194,9 +194,9 @@ describe("[data] WithBuilder", async () => {
);
const res = qb.compile();
expect(res.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"',
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
);
expect(res.parameters).toEqual(["categories.single", 1, 0]);
expect(res.parameters).toEqual(["categories.single", 1]);
const qb2 = WithBuilder.addClause(
em,
@@ -273,9 +273,9 @@ describe("[data] WithBuilder", async () => {
//prettyPrintQb(qb);
expect(qb.compile().sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"',
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
});
test("compiles with many", async () => {
@@ -315,9 +315,9 @@ describe("[data] WithBuilder", async () => {
);
expect(qb.compile().sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
);
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
expect(qb.compile().parameters).toEqual([1, 12, 0]);
});
test("returns correct result", async () => {
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../../../src/core/utils";
import { Type } from "@sinclair/typebox";
import { Entity, EntityIndex, Field } from "../../../../src/data";
class TestField extends Field {
@@ -39,4 +39,28 @@ describe("[data] PrimaryField", async () => {
expect(field.transformPersist(1)).rejects.toThrow();
expect(field.transformRetrieve(1)).toBe(1);
});
test("format", () => {
const uuid = new PrimaryField("uuid", { format: "uuid" });
expect(uuid.format).toBe("uuid");
expect(uuid.fieldType).toBe("text");
expect(uuid.getNewValue()).toBeString();
expect(uuid.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<string>",
import: [{ package: "kysely", name: "Generated" }],
});
const integer = new PrimaryField("integer", { format: "integer" });
expect(integer.format).toBe("integer");
expect(integer.fieldType).toBe("integer");
expect(integer.getNewValue()).toBeUndefined();
expect(integer.toType()).toEqual({
required: true,
comment: undefined,
type: "Generated<number>",
import: [{ package: "kysely", name: "Generated" }],
});
});
});
+22 -41
View File
@@ -1,5 +1,23 @@
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 () => {
test("Simple Subflow", async () => {
@@ -22,8 +40,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution();
await execution.start();
/*console.log(execution.logs);
console.log(execution.getResponse());*/
expect(execution.getResponse()).toEqual("Subflow output: subflow");
});
@@ -40,8 +56,8 @@ describe("SubFlowTask", async () => {
loop: true,
input: [1, 2, 3],
});
const task3 = new RenderTask("render2", {
render: `Subflow output: {{ sub.output | join: ", " }}`,
const task3 = new StringifyTask("stringify", {
input: "{{ sub.output }}",
});
const flow = new Flow("test", [task, task2, task3], []);
@@ -51,41 +67,6 @@ describe("SubFlowTask", async () => {
const execution = flow.createExecution();
await execution.start();
console.log("errors", execution.getErrors());
/*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");
expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"');
});
});
+1 -59
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { Type } from "../../src/core/utils";
import { Type } from "@sinclair/typebox";
import { Task } from "../../src/flows";
import { dynamic } from "../../src/flows/tasks/Task";
@@ -51,62 +51,4 @@ describe("Task", async () => {
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 { Hono } from "hono";
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 { dynamic } from "../../src/flows/tasks/Task";
+2 -1
View File
@@ -1,7 +1,8 @@
// eslint-disable-next-line import/no-unresolved
import { describe, expect, test } from "bun:test";
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";
/*beforeAll(disableConsoleLog);
+3 -2
View File
@@ -38,14 +38,15 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" };
}
type ConsoleSeverity = "log" | "warn" | "error";
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
const _oldConsoles = {
debug: console.debug,
log: console.log,
warn: console.warn,
error: console.error,
};
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { createApp } from "../../src";
import { createApp } from "core/test/utils";
import { Api } from "../../src/Api";
describe("integration config", () => {
+4 -2
View File
@@ -1,7 +1,8 @@
/// <reference types="@types/bun" />
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
@@ -43,8 +44,9 @@ beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test.only("accepts direct", async () => {
test("accepts direct", async () => {
const app = await makeApp();
console.log("app", app);
const file = Bun.file(path);
const name = makeName("png");
+66 -2
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { createApp } from "../../src";
import { createApp } from "core/test/utils";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "../../src/data";
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
@@ -69,7 +69,7 @@ describe("AppAuth", () => {
},
body: JSON.stringify({
email: "some@body.com",
password: "123456",
password: "12345678",
}),
});
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 () => {
const app = createApp({
initialConfig: {
@@ -94,6 +153,7 @@ describe("AppAuth", () => {
});
await app.build();
app.registerAdminController();
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
// register custom route
@@ -103,6 +163,10 @@ describe("AppAuth", () => {
await app.server.request("/api/system/ping");
await app.server.request("/test");
expect(spy.mock.calls.length).toBe(0);
// admin route
await app.server.request("/");
expect(spy.mock.calls.length).toBe(1);
});
+2 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import { em, entity, text } from "../../src/data";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/modules";
+17 -17
View File
@@ -1,18 +1,20 @@
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 { DummyConnection } from "../../src/data/connection/DummyConnection";
import { Module } from "../../src/modules/Module";
import { ModuleHelper } from "modules/ModuleHelper";
function createModule<Schema extends TSchema>(schema: Schema) {
class TestModule extends Module<typeof schema> {
getSchema() {
return schema;
}
toJSON() {
override toJSON() {
return this.config;
}
useForceParse() {
override useForceParse() {
return true;
}
}
@@ -45,9 +47,9 @@ describe("Module", async () => {
}
prt = {
ensureEntity: this.ensureEntity.bind(this),
ensureIndex: this.ensureIndex.bind(this),
ensureSchema: this.ensureSchema.bind(this),
ensureEntity: this.ctx.helper.ensureEntity.bind(this.ctx.helper),
ensureIndex: this.ctx.helper.ensureIndex.bind(this.ctx.helper),
ensureSchema: this.ctx.helper.ensureSchema.bind(this.ctx.helper),
};
get em() {
@@ -62,7 +64,11 @@ describe("Module", async () => {
_em.relations,
_em.indices,
);
return new M({} as any, { em, flags: Module.ctx_flags } as any);
const ctx = {
em,
flags: Module.ctx_flags,
};
return new M({} as any, { ...ctx, helper: new ModuleHelper(ctx as any) } as any);
}
function flat(_em: EntityManager) {
return {
@@ -142,14 +148,9 @@ describe("Module", async () => {
// this should only add the field "important"
m.prt.ensureEntity(
entity(
"u",
{
important: text(),
},
undefined,
"system",
),
entity("u", {
important: text(),
}),
);
expect(m.ctx.flags.sync_required).toBe(true);
@@ -158,8 +159,7 @@ describe("Module", async () => {
{
name: "u",
fields: ["id", "name", "important"],
// ensured type must be present
type: "system",
type: "regular",
},
{
name: "p",
+6 -5
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils";
import { Connection, entity, text } from "../../src/data";
import { Module } from "../../src/modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
import { Type } from "@sinclair/typebox";
import { Connection, entity, text } from "data";
import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDummyConnection } from "../helper";
import { diff } from "core/object/diff";
import type { Static } from "@sinclair/typebox";
+6 -1
View File
@@ -8,10 +8,11 @@ import { Default, stripMark } from "../../src/core/utils";
import { EntityManager } from "../../src/data";
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
import { getDummyConnection } from "../helper";
import { ModuleHelper } from "modules/ModuleHelper";
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
const { dummyConnection } = getDummyConnection();
return {
const ctx = {
connection: dummyConnection,
server: new Hono(),
em: new EntityManager([], dummyConnection),
@@ -21,6 +22,10 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
logger: new DebugLogger(false),
...overrides,
};
return {
...ctx,
helper: new ModuleHelper(ctx as any),
} as any;
}
export function moduleTestSuite(module: { new (): Module }) {
+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 + "/", "")),
);
}
+56 -15
View File
@@ -1,5 +1,6 @@
import { $ } from "bun";
import * as tsup from "tsup";
import pkg from "./package.json" with { type: "json" };
const args = process.argv.slice(2);
const watch = args.includes("--watch");
@@ -8,8 +9,13 @@ const types = args.includes("--types");
const sourcemap = args.includes("--sourcemap");
const clean = args.includes("--clean");
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
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 {} +`;
}
@@ -21,11 +27,11 @@ function buildTypes() {
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
console.log("Types built");
console.info("Types built");
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
console.log("Types aliased");
console.info("Types aliased");
types_running = false;
},
});
@@ -47,14 +53,21 @@ if (types && !watch) {
}
function banner(title: string) {
console.log("");
console.log("=".repeat(40));
console.log(title.toUpperCase());
console.log("-".repeat(40));
console.info("");
console.info("=".repeat(40));
console.info(title.toUpperCase());
console.info("-".repeat(40));
}
// collection of always-external packages
const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
const external = [
"bun:test",
"node:test",
"node:assert/strict",
"@libsql/client",
"bknd",
/^bknd\/.*/,
] as const;
/**
* Building backend and general API
@@ -65,12 +78,14 @@ async function buildApi() {
minify,
sourcemap,
watch,
define,
entry: [
"src/index.ts",
"src/core/index.ts",
"src/core/utils/index.ts",
"src/data/index.ts",
"src/media/index.ts",
"src/plugins/index.ts",
],
outDir: "dist",
external: [...external],
@@ -101,6 +116,7 @@ async function buildUi() {
minify,
sourcemap,
watch,
define,
external: [
...external,
"react",
@@ -160,6 +176,7 @@ async function buildUiElements() {
minify,
sourcemap,
watch,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
@@ -211,14 +228,15 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
},
...overrides,
define: {
__isDev: "0",
...define,
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?(hono|libsql).*?/,
/^@?(hono).*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
@@ -235,14 +253,14 @@ async function buildAdapters() {
// specific adatpers
await tsup.build(baseConfig("react-router"));
await tsup.build(baseConfig("bun"));
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
await tsup.build(
baseConfig("cloudflare", {
external: [/^kysely/],
baseConfig("bun", {
external: [/^bun\:.*/],
}),
);
await tsup.build(baseConfig("astro"));
await tsup.build(baseConfig("aws"));
await tsup.build(baseConfig("cloudflare"));
await tsup.build({
...baseConfig("vite"),
@@ -258,6 +276,29 @@ async function buildAdapters() {
...baseConfig("node"),
platform: "node",
});
await tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
});
await tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
});
await tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
});
}
await buildApi();
+5 -1
View File
@@ -142,6 +142,7 @@ const adapters = {
},
nextjs: {
dir: path.join(basePath, "examples/nextjs"),
env: "TEST_TIMEOUT=20000",
clean: async function () {
const cwd = path.relative(process.cwd(), this.dir);
await $`cd ${cwd} && rm -rf .nextjs data.db`;
@@ -195,7 +196,8 @@ async function testAdapter(name: keyof typeof adapters) {
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`;
const add_env = "env" in config && config.env ? config.env : "";
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
console.log("DONE!");
while (!proc.killed) {
@@ -205,6 +207,8 @@ async function testAdapter(name: keyof typeof adapters) {
}
}
// run with: TEST_ADAPTER=astro bun run e2e/adapters.ts
// (modify `test:e2e` to `test:e2e:ui` to see the UI)
if (process.env.TEST_ADAPTER) {
await testAdapter(process.env.TEST_ADAPTER as any);
} else {
+61 -19
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.11.2",
"version": "0.15.0-rc.10",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -13,12 +13,15 @@
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "vite",
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
"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:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
"build:cli": "bun run build.cli.ts",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc -p tsconfig.build.json --noEmit",
@@ -31,11 +34,9 @@
"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:node": "vitest run",
"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",
@@ -48,40 +49,40 @@
"@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.15.2",
"@hono/swagger-ui": "^0.5.1",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30",
"@sinclair/typebox": "0.34.30",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
"aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8",
"hono": "^4.7.4",
"hono": "^4.7.11",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "^0.1.0",
"kysely": "^0.27.6",
"liquidjs": "^10.21.0",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.3",
"swr": "^2.3.3",
"wrangler": "^4.4.1"
"swr": "^2.3.3"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@cloudflare/vitest-pool-workers": "^0.8.38",
"@cloudflare/workers-types": "^4.20250606.0",
"@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.3.2",
"@hono/vite-dev-server": "^0.19.0",
"@hono/typebox-validator": "^0.3.3",
"@hono/vite-dev-server": "^0.19.1",
"@hookform/resolvers": "^4.1.3",
"@libsql/kysely-libsql": "^0.4.1",
"@libsql/client": "^0.15.9",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
@@ -102,8 +103,11 @@
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"kysely-d1": "^0.3.0",
"kysely-generic-sqlite": "^1.2.1",
"libsql-stateless-easy": "^1.8.0",
"open": "^10.1.0",
"openapi-types": "^12.1.3",
"picocolors": "^1.1.1",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
@@ -120,13 +124,14 @@
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"vite": "^6.2.1",
"uuid": "^11.1.0",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0"
},
"optionalDependencies": {
"@hono/node-server": "^1.13.8"
"@hono/node-server": "^1.14.3"
},
"peerDependencies": {
"react": ">=19",
@@ -181,6 +186,25 @@
"import": "./dist/media/index.js",
"require": "./dist/media/index.js"
},
"./plugins": {
"types": "./dist/types/plugins/index.d.ts",
"import": "./dist/plugins/index.js",
"require": "./dist/plugins/index.js"
},
"./adapter/sqlite": {
"types": "./dist/types/adapter/sqlite/edge.d.ts",
"import": {
"workerd": "./dist/adapter/sqlite/edge.js",
"edge-light": "./dist/adapter/sqlite/edge.js",
"netlify": "./dist/adapter/sqlite/edge.js",
"vercel": "./dist/adapter/sqlite/edge.js",
"browser": "./dist/adapter/sqlite/edge.js",
"bun": "./dist/adapter/sqlite/bun.js",
"node": "./dist/adapter/sqlite/node.js",
"default": "./dist/adapter/sqlite/node.js"
},
"require": "./dist/adapter/sqlite/node.js"
},
"./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
@@ -229,6 +253,24 @@
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
},
"typesVersions": {
"*": {
"data": ["./dist/types/data/index.d.ts"],
"core": ["./dist/types/core/index.d.ts"],
"utils": ["./dist/types/core/utils/index.d.ts"],
"cli": ["./dist/types/cli/index.d.ts"],
"media": ["./dist/types/media/index.d.ts"],
"plugins": ["./dist/types/plugins/index.d.ts"],
"adapter": ["./dist/types/adapter/index.d.ts"],
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
}
},
"publishConfig": {
"access": "public"
},
+2 -1
View File
@@ -3,6 +3,7 @@ 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);
const timeout = process.env.TEST_TIMEOUT ? Number.parseInt(process.env.TEST_TIMEOUT) : 5000;
export default defineConfig({
testMatch: "**/*.e2e-spec.ts",
@@ -12,7 +13,7 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
timeout: 20000,
timeout,
use: {
baseURL: baseUrl,
trace: "on-first-retry",
+78 -25
View File
@@ -1,13 +1,19 @@
import type { SafeUser } from "auth";
import { AuthApi } from "auth/api/AuthApi";
import { DataApi } from "data/api/DataApi";
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
import { DataApi, type DataApiOptions } from "data/api/DataApi";
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 { omitKeys } from "core/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser;
export type ApiFetcher = (
input: RequestInfo | URL,
init?: RequestInit,
) => Response | Promise<Response>;
declare global {
interface Window {
__BKND__: {
@@ -16,14 +22,24 @@ declare global {
}
}
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
export type ApiOptions = {
host?: string;
headers?: Headers;
key?: string;
localStorage?: boolean;
fetcher?: typeof fetch;
storage?: {
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;
verified?: boolean;
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
} & (
| {
token?: string;
@@ -56,18 +72,18 @@ export class Api {
this.verified = options.verified === true;
// 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.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options) {
} else if ("token" in options && options.token) {
this.token_transport = "header";
this.updateToken(options.token);
this.updateToken(options.token, { trigger: false });
// then check for an user object
} else if ("user" in options) {
} else if ("user" in options && options.user) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
@@ -110,18 +126,30 @@ export class Api {
this.updateToken(headerToken);
return;
}
} else if (this.options.localStorage) {
const token = localStorage.getItem(this.tokenKey);
if (token) {
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
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.verified = false;
@@ -131,17 +159,25 @@ export class Api {
this.user = undefined;
}
if (this.options.localStorage) {
if (this.storage) {
const key = this.tokenKey;
if (token) {
localStorage.setItem(key, token);
this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
} 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) {
@@ -211,15 +247,32 @@ export class Api {
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
this.data = new DataApi(
{
...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,
);
this.media = new MediaApi(baseParams, fetcher);
}
}
+102 -53
View File
@@ -1,7 +1,8 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core";
import { $console } from "core/utils";
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import type { Hono } from "hono";
import {
ModuleManager,
@@ -14,15 +15,29 @@ import {
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be there
import { Api, type ApiOptions } from "Api";
import type { MaybePromise } from "core/types";
import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers";
export type AppPlugin = (app: App) => Promise<void> | void;
// biome-ignore format: must be here
import { Api, type ApiOptions } from "Api";
export type AppPluginConfig = {
name: string;
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
beforeBuild?: () => MaybePromise<void>;
onBuilt?: () => MaybePromise<void>;
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
onFirstBoot?: () => MaybePromise<void>;
onBoot?: () => MaybePromise<void>;
};
export type AppPlugin = (app: App) => AppPluginConfig;
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
export class AppConfigUpdatedEvent extends AppEvent {
export class AppConfigUpdatedEvent extends AppEvent<{
module: string;
config: ModuleConfigs[keyof ModuleConfigs];
}> {
static override slug = "app-config-updated";
}
export class AppBuiltEvent extends AppEvent {
@@ -50,16 +65,13 @@ export type AppOptions = {
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
drivers?: {
email?: IEmailDriver;
cache?: ICacheDriver;
};
};
export type CreateAppConfig = {
connection?:
| Connection
| {
// @deprecated
type: "libsql";
config: LibSqlCredentials;
}
| LibSqlCredentials;
connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs;
options?: AppOptions;
};
@@ -67,29 +79,40 @@ export type CreateAppConfig = {
export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App {
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
plugins: Map<string, AppPluginConfig> = new Map();
drivers: Options["drivers"] = {};
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _building: boolean = false;
constructor(
private connection: Connection,
public connection: C,
_initialConfig?: InitialModuleConfigs,
private options?: AppOptions,
private options?: Options,
) {
this.plugins = options?.plugins ?? [];
this.drivers = options?.drivers ?? {};
for (const plugin of options?.plugins ?? []) {
const config = plugin(this);
if (this.plugins.has(config.name)) {
throw new Error(`Plugin ${config.name} already registered`);
}
this.plugins.set(config.name, config);
}
this.runPlugins("onBoot");
this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}),
initial: _initialConfig,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
onModulesBuilt: this.onModulesBuilt.bind(this),
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
@@ -98,6 +121,32 @@ export class App {
return this.modules.ctx().emgr;
}
protected async runPlugins<Key extends keyof AppPluginConfig>(
key: Key,
...args: any[]
): Promise<{ name: string; result: any }[]> {
const results: { name: string; result: any }[] = [];
for (const [name, config] of this.plugins) {
try {
if (key in config && config[key]) {
const fn = config[key];
if (fn && typeof fn === "function") {
$console.debug(`[Plugin:${name}] ${key}`);
// @ts-expect-error
const result = await fn(...args);
results.push({
name,
result,
});
}
}
} catch (e) {
$console.warn(`[Plugin:${name}] error running "${key}"`, String(e));
}
}
return results as any;
}
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
// prevent multiple concurrent builds
if (this._building) {
@@ -106,6 +155,8 @@ export class App {
}
if (!options?.forceBuild) return;
}
await this.runPlugins("beforeBuild");
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
@@ -117,13 +168,10 @@ export class App {
guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController());
// load plugins
if (this.plugins.length > 0) {
await Promise.all(this.plugins.map((plugin) => plugin(this)));
}
// emit built event
$console.log("App built");
await this.emgr.emit(new AppBuiltEvent({ app: this }));
await this.runPlugins("onBuilt");
// first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) {
@@ -151,7 +199,7 @@ export class App {
}
get fetch(): Hono["fetch"] {
return this.server.fetch;
return this.server.fetch as any;
}
get module() {
@@ -220,15 +268,16 @@ export class App {
$console.log("App config updated", module);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this, module, config }));
}
async onFirstBoot() {
protected async onFirstBoot() {
$console.log("App first boot");
this.trigger_first_boot = true;
await this.runPlugins("onFirstBoot");
}
async onServerInit(server: Hono<ServerEnv>) {
protected 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 }));
@@ -253,35 +302,35 @@ export class App {
break;
}
});
// call server init if set
if (this.options?.manager?.onServerInit) {
this.options.manager.onServerInit(server);
}
await this.runPlugins("onServerInit", server);
}
protected async onModulesBuilt(ctx: ModuleBuildContext) {
const results = (await this.runPlugins("schema")) as {
name: string;
result: ReturnType<typeof prototypeEm>;
}[];
if (results.length > 0) {
for (const { name, result } of results) {
if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result);
}
}
}
}
}
export function createApp(config: CreateAppConfig = {}) {
let connection: Connection | undefined = undefined;
try {
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else if (typeof config.connection === "object") {
if ("type" in config.connection) {
$console.warn(
"Using deprecated connection type 'libsql', use the 'config' object directly.",
);
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection(config.connection);
}
} else {
connection = new LibsqlConnection({ url: ":memory:" });
$console.warn("No connection provided, using in-memory database");
}
} catch (e) {
$console.error("Could not create connection", e);
}
if (!connection) {
if (!config.connection || !Connection.isConnection(config.connection)) {
throw new Error("Invalid connection");
}
return new App(connection, config.initialConfig, config.options);
return new App(config.connection, config.initialConfig, config.options);
}
+7 -3
View File
@@ -2,10 +2,11 @@
import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { registerLocalMediaAdapter } from ".";
import { config } from "bknd/core";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
import type { App } from "App";
type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
@@ -33,8 +34,11 @@ export function createHandler<Env = BunEnv>(
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts);
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
}
return app.fetch(req);
};
}
@@ -72,5 +76,5 @@ export function serve<Env = BunEnv>(
),
});
console.log(`Server is running on http://localhost:${port}`);
console.info(`Server is running on http://localhost:${port}`);
}
@@ -0,0 +1,15 @@
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
import { Database } from "bun:sqlite";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
makeConnection: () => ({
connection: bunSqlite({ database: new Database(":memory:") }),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
@@ -0,0 +1,40 @@
import { Database } from "bun:sqlite";
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = {
database: Database;
};
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
let db: Database;
if (config) {
if ("database" in config) {
db = config.database;
} else {
db = new Database(config.url);
}
} else {
db = new Database(":memory:");
}
return genericSqlite("bun-sqlite", db, (utils) => {
//const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
});
}
+2
View File
@@ -1 +1,3 @@
export * from "./bun.adapter";
export * from "../node/storage";
export * from "./connection/BunSqliteConnection";
+5 -1
View File
@@ -1,7 +1,11 @@
import { expect, test, mock } from "bun:test";
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
export const bunTestRunner = {
describe,
expect,
test,
mock,
beforeEach,
afterEach,
afterAll,
};
@@ -1,63 +0,0 @@
/// <reference types="@cloudflare/workers-types" />
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
import type { QB } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig = {
binding: D1Database;
};
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV", "_cf_METADATA"],
});
}
}
export class D1Connection extends SqliteConnection {
protected override readonly supported = {
batching: true,
};
constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomD1Dialect({ database: config.binding }),
plugins,
});
super(kysely, {}, plugins);
}
get client(): D1Database {
return this.config.binding;
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries],
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const db = this.config.binding;
const res = await db.batch(
queries.map((q) => {
const { sql, parameters } = q.compile();
return db.prepare(sql).bind(...parameters);
}),
);
// let it run through plugins
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.results);
data.push(rows);
}
return data;
}
}
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig } from "./config";
import { makeConfig, type CfMakeConfigArgs } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
@@ -13,36 +13,38 @@ describe("cf adapter", () => {
const DB_URL = ":memory:";
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
request: request ?? (null as any),
env: env ?? { DB_URL },
env: env ?? { url: DB_URL },
ctx: ctx ?? (null as any),
});
it("makes config", async () => {
expect(
makeConfig(
{
connection: { url: DB_URL },
},
{},
),
).toEqual({ connection: { url: DB_URL } });
const staticConfig = makeConfig(
{
connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
},
$ctx({ DB_URL }),
);
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined();
expect(
makeConfig(
{
app: (env) => ({
connection: { url: env.DB_URL },
}),
},
{
DB_URL,
},
),
).toEqual({ connection: { url: DB_URL } });
const dynamicConfig = makeConfig(
{
app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL },
}),
},
$ctx({ DB_URL }),
);
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined();
});
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await makeApp(c, { env: a } as any, o);
},
makeHandler: (c, a, o) => {
return async (request: any) => {
const app = await makeApp(
@@ -50,7 +52,7 @@ describe("cf adapter", () => {
c ?? {
connection: { url: DB_URL },
},
a,
a!,
o,
);
return app.fetch(request);
@@ -7,8 +7,15 @@ import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core/utils";
export type CloudflareEnv = object;
declare global {
namespace Cloudflare {
interface Env {}
}
}
export type CloudflareEnv = Cloudflare.Env;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => {
@@ -16,11 +23,17 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
dobj?: DurableObjectNamespace;
db?: D1Database;
};
d1?: {
session?: boolean;
transport?: "header" | "cookie";
first?: D1SessionConstraint;
};
static?: "kv" | "assets";
key?: string;
keepAliveSeconds?: number;
forceHttps?: boolean;
manifest?: string;
registerMedia?: boolean | ((env: Env) => void);
};
export type Context<Env = CloudflareEnv> = {
@@ -37,7 +50,7 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
const url = new URL(request.url);
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") {
throw new Error("manifest is required with static 'kv'");
}
+142 -15
View File
@@ -1,49 +1,176 @@
/// <reference types="@cloudflare/workers-types" />
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { D1Connection } from "./D1Connection";
import { d1Sqlite } from "./connection/D1Connection";
import { Connection } from "bknd/data";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { ExecutionContext } from "hono";
import type { Context, ExecutionContext } from "hono";
import { $console } from "core/utils";
import { setCookie } from "hono/cookie";
import { sqlite } from "bknd/adapter/sqlite";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
d1_session: {
cookie: "cf_d1_session",
header: "x-cf-d1-session",
},
};
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
env: Env;
ctx?: ExecutionContext;
request?: Request;
};
function getCookieValue(cookies: string | null, name: string) {
if (!cookies) return null;
for (const cookie of cookies.split("; ")) {
const [key, value] = cookie.split("=");
if (key === name && value) {
return decodeURIComponent(value);
}
}
return null;
}
export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
const headerKey = constants.d1_session.header;
const cookieKey = constants.d1_session.cookie;
const transport = config.d1?.transport;
return {
get: (request?: Request): D1SessionBookmark | undefined => {
if (!request || !config.d1?.session) return undefined;
if (!transport || transport === "cookie") {
const cookies = request.headers.get("Cookie");
if (cookies) {
const cookie = getCookieValue(cookies, cookieKey);
if (cookie) {
return cookie;
}
}
}
if (!transport || transport === "header") {
if (request.headers.has(headerKey)) {
return request.headers.get(headerKey) as any;
}
}
return undefined;
},
set: (c: Context, d1?: D1DatabaseSession) => {
if (!d1 || !config.d1?.session) return;
const session = d1.getBookmark();
if (session) {
if (!transport || transport === "header") {
c.header(headerKey, session);
}
if (!transport || transport === "cookie") {
setCookie(c, cookieKey, session, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: 60 * 5, // 5 minutes
});
}
}
},
};
}
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
args?: CfMakeConfigArgs<Env>,
) {
if (!media_registered) {
registerMedia(args as any);
if (!media_registered && config.registerMedia !== false) {
if (typeof config.registerMedia === "function") {
config.registerMedia(args?.env as any);
} else {
registerMedia(args?.env as any);
}
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args);
const bindings = config.bindings?.(args);
if (!appConfig.connection) {
const appConfig = makeAdapterConfig(config, args?.env);
// if connection instance is given, don't do anything
// other than checking if D1 session is defined
if (Connection.isConnection(appConfig.connection)) {
if (config.d1?.session) {
// we cannot guarantee that db was opened with session
throw new Error(
"D1 session don't work when D1 is directly given as connection. Define it in bindings instead.",
);
}
// if connection is given, try to open with unified sqlite adapter
} else if (appConfig.connection) {
appConfig.connection = sqlite(appConfig.connection);
// if connection is not given, but env is set
// try to make D1 from bindings
} else if (args?.env) {
const bindings = config.bindings?.(args?.env);
const sessionHelper = d1SessionHelper(config);
const sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined;
let db: D1Database | undefined;
// if db is given in bindings, use it
if (bindings?.db) {
console.log("Using database from bindings");
$console.debug("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
// scan for D1Database in args
} else {
const binding = getBinding(args.env, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
$console.debug(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
// if db is found, check if session is requested
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
if (config.d1?.session) {
session = db.withSession(sessionId ?? config.d1?.first);
if (!session) {
throw new Error("Couldn't create session");
}
appConfig.connection = d1Sqlite({ binding: session });
appConfig.options = {
...appConfig.options,
manager: {
...appConfig.options?.manager,
onServerInit: (server) => {
server.use(async (c, next) => {
sessionHelper.set(c, session);
await next();
});
},
},
};
} else {
appConfig.connection = d1Sqlite({ binding: db });
}
}
}
if (!Connection.isConnection(appConfig.connection)) {
throw new Error("Couldn't find database connection");
}
return appConfig;
}
@@ -0,0 +1,75 @@
/// <reference types="@cloudflare/workers-types" />
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
binding: DB;
};
export function d1Sqlite<DB extends D1Database | D1DatabaseSession = D1Database>(
config: D1ConnectionConfig<DB>,
) {
const db = config.binding;
return genericSqlite(
"d1-sqlite",
db,
(utils) => {
const getStmt = (sql: string, parameters?: any[] | readonly any[]) =>
db.prepare(sql).bind(...(parameters || []));
const mapResult = (res: D1Result<any>): QueryResult<any> => {
if (res.error) {
throw new Error(res.error);
}
const numAffectedRows =
res.meta.changes > 0 ? utils.parseBigInt(res.meta.changes) : undefined;
const insertId = res.meta.last_row_id
? utils.parseBigInt(res.meta.last_row_id)
: undefined;
return {
insertId,
numAffectedRows,
rows: res.results,
// @ts-ignore
meta: res.meta,
};
};
return {
db,
batch: async (stmts) => {
const res = await db.batch(
stmts.map(({ sql, parameters }) => {
return getStmt(sql, parameters);
}),
);
return res.map(mapResult);
},
query: utils.buildQueryFn({
all: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep.all()).rows;
},
run: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep.run());
},
}),
close: () => {},
};
},
{
supports: {
batching: true,
softscans: false,
},
excludeTables: ["_cf_KV", "_cf_METADATA"],
},
);
}
@@ -0,0 +1,33 @@
import { describe, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { d1Sqlite } from "./D1Connection";
describe("d1Sqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
d1Databases: ["DB"],
});
const binding = (await mf.getD1Database("DB")) as D1Database;
return {
connection: d1Sqlite({ binding }),
dispose: () => mf.dispose(),
};
},
rawDialectDetails: [
"meta.served_by",
"meta.duration",
"meta.changes",
"meta.changed_db",
"meta.size_after",
"meta.rows_read",
"meta.rows_written",
],
});
});
@@ -0,0 +1,80 @@
/// <reference types="@cloudflare/workers-types" />
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
export type D1ConnectionConfig<DB extends DurableObjecSql> =
| DurableObjectState
| {
sql: DB;
};
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
const db = "sql" in config ? config.sql : config.storage.sql;
return genericSqlite(
"do-sqlite",
db,
(utils) => {
// must be async to work with the miniflare mock
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
await db.exec(sql, ...(parameters || []));
const mapResult = (
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
): QueryResult<any> => {
const numAffectedRows =
cursor.rowsWritten > 0 ? utils.parseBigInt(cursor.rowsWritten) : undefined;
const insertId = undefined;
const obj = {
insertId,
numAffectedRows,
rows: cursor.toArray() || [],
// @ts-ignore
meta: {
rowsWritten: cursor.rowsWritten,
rowsRead: cursor.rowsRead,
databaseSize: db.databaseSize,
},
};
//console.info("mapResult", obj);
return obj;
};
return {
db,
batch: async (stmts) => {
// @todo: maybe wrap in a transaction?
// because d1 implicitly does a transaction on batch
return Promise.all(
stmts.map(async (stmt) => {
return mapResult(await getStmt(stmt.sql, stmt.parameters));
}),
);
},
query: utils.buildQueryFn({
all: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep).rows;
},
run: async (sql, parameters) => {
const prep = getStmt(sql, parameters);
return mapResult(await prep);
},
}),
close: () => {},
};
},
{
supports: {
batching: true,
softscans: false,
},
excludeTables: ["_cf_KV", "_cf_METADATA"],
},
);
}
@@ -0,0 +1,92 @@
/// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection";
const script = `
import { DurableObject } from "cloudflare:workers";
export class TestObject extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.storage = ctx.storage;
}
async exec(sql, ...parameters) {
//return { sql, parameters }
const cursor = this.storage.sql.exec(sql, ...parameters);
return {
rows: cursor.toArray() || [],
rowsWritten: cursor.rowsWritten,
rowsRead: cursor.rowsRead,
databaseSize: this.storage.sql.databaseSize,
}
}
async databaseSize() {
return this.storage.sql.databaseSize;
}
}
export default {
async fetch(request, env) {
const stub = env.TEST_OBJECT.get(env.TEST_OBJECT.idFromName("test"));
return stub.fetch(request);
}
}
`;
describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
const mf = new Miniflare({
modules: true,
durableObjects: { TEST_OBJECT: { className: "TestObject", useSQLite: true } },
script,
});
const ns = await mf.getDurableObjectNamespace("TEST_OBJECT");
const id = ns.idFromName("test");
const stub = ns.get(id) as unknown as DurableObjectStub<
Rpc.DurableObjectBranded & {
exec: (sql: string, ...parameters: any[]) => Promise<any>;
}
>;
const stubs: any[] = [];
const mock = {
databaseSize: 0,
exec: async function (sql: string, ...parameters: any[]) {
// @ts-ignore
const result = (await stub.exec(sql, ...parameters)) as any;
this.databaseSize = result.databaseSize;
stubs.push(result);
return {
toArray: () => result.rows,
rowsWritten: result.rowsWritten,
rowsRead: result.rowsRead,
};
},
};
return {
connection: doSqlite({ sql: mock as any }),
dispose: async () => {
await Promise.all(
stubs.map((stub) => {
try {
return stub[Symbol.dispose]();
} catch (e) {}
}),
);
await mf.dispose();
},
};
},
rawDialectDetails: ["meta.rowsWritten", "meta.rowsRead", "meta.databaseSize"],
});
});
@@ -0,0 +1,45 @@
import type { ICacheDriver } from "core/drivers";
interface WorkersKVCacheOptions {
// default time-to-live in seconds
defaultTTL?: number;
// prefix for the cache key
cachePrefix?: string;
}
export class WorkersKVCacheDriver implements ICacheDriver {
protected readonly kv: KVNamespace;
protected readonly defaultTTL?: number;
protected readonly cachePrefix: string;
constructor(kv: KVNamespace, options: WorkersKVCacheOptions = {}) {
this.kv = kv;
this.cachePrefix = options.cachePrefix ?? "";
this.defaultTTL = options.defaultTTL;
}
protected getKey(key: string): string {
return this.cachePrefix + key;
}
async get(key: string): Promise<string | undefined> {
const value = await this.kv.get(this.getKey(key));
return value === null ? undefined : value;
}
async set(key: string, value: string, ttl?: number): Promise<void> {
let expirationTtl = ttl ?? this.defaultTTL;
if (expirationTtl) {
expirationTtl = Math.max(expirationTtl, 60);
}
await this.kv.put(this.getKey(key), value, { expirationTtl: expirationTtl });
}
async del(key: string): Promise<void> {
await this.kv.delete(this.getKey(key));
}
}
export const cacheWorkersKV = (kv: KVNamespace, options?: WorkersKVCacheOptions) => {
return new WorkersKVCacheDriver(kv, options);
};
@@ -0,0 +1,34 @@
import { describe, vi, afterAll, beforeAll } from "vitest";
import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare";
describe("cacheWorkersKV", async () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.restoreAllMocks();
});
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
kvNamespaces: ["KV"],
});
const kv = (await mf.getKVNamespace("KV")) as unknown as KVNamespace;
cacheDriverTestSuite(viTestRunner, {
makeCache: () => cacheWorkersKV(kv),
setTime: (ms: number) => {
vi.advanceTimersByTime(ms);
},
options: {
minTTL: 60,
// doesn't work with miniflare
skipTTL: true,
},
});
});
+10 -4
View File
@@ -1,10 +1,10 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig };
export { d1Sqlite, type D1ConnectionConfig };
export {
getBinding,
getBindings,
@@ -12,7 +12,13 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export { StorageR2Adapter } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
export function d1(config: D1ConnectionConfig) {
return new D1Connection(config);
// for compatibility with old code
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
config: D1ConnectionConfig<DB>,
) {
return d1Sqlite<DB>(config);
}
+4 -3
View File
@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
{ env, ctx, ...args }: Context<Env>,
args: Context<Env>,
) {
const { env, ctx } = args;
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -20,7 +21,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
const app = await createRuntimeApp(
{
...makeConfig(config, env),
...makeConfig(config, args),
initialConfig,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx);
@@ -41,7 +42,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
await config.beforeBuild?.(app);
},
},
{ env, ctx, ...args },
args,
);
if (!cachedConfig) {
+3 -2
View File
@@ -3,6 +3,7 @@ import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core/utils";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
@@ -13,7 +14,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
const key = config.key ?? "app";
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();
@@ -63,7 +64,7 @@ export class DurableBkndApp extends DurableObject {
"type" in config.connection &&
config.connection.type === "libsql"
) {
config.connection.config.protocol = "wss";
//config.connection.config.protocol = "wss";
}
this.app = await createRuntimeApp({
+5 -5
View File
@@ -1,13 +1,13 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext } from "../config";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
args?: CfMakeConfigArgs<Env>,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
@@ -20,10 +20,10 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
config.onBuilt?.(app);
await config.onBuilt?.(app);
},
},
ctx.env,
ctx,
opts,
);
}
@@ -1,32 +0,0 @@
import { createWriteStream, readFileSync } from "node:fs";
import { test } from "node:test";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node";
import path from "node:path";
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
console.log = async (message: any) => {
const tty = createWriteStream("/dev/tty");
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
return tty.write(`${msg}\n`);
};
test("StorageR2Adapter", async () => {
const mf = new Miniflare({
modules: true,
script: "export default { async fetch() { return new Response(null); } }",
r2Buckets: ["BUCKET"],
});
const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
const adapter = new StorageR2Adapter(bucket);
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(nodeTestRunner, adapter, file);
await mf.dispose();
});
@@ -1,8 +1,11 @@
import { registries } from "bknd";
import { isDebug } from "bknd/core";
import { StringEnum, Type } from "bknd/utils";
// @ts-ignore
import { StringEnum } from "bknd/utils";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export function makeSchema(bindings: string[] = []) {
return Type.Object(
@@ -61,46 +64,49 @@ export class StorageR2Adapter extends StorageAdapter {
async putObject(key: string, body: FileBody) {
try {
const res = await this.bucket.put(key, body);
const res = await this.bucket.put(this.getKey(key), body);
return res?.etag;
} catch (e) {
return undefined;
}
}
async listObjects(
prefix?: string,
): Promise<{ key: string; last_modified: Date; size: number }[]> {
const list = await this.bucket.list({ limit: 50 });
async listObjects(prefix = ""): Promise<{ key: string; last_modified: Date; size: number }[]> {
const list = await this.bucket.list({ limit: 50, prefix: this.getKey(prefix) });
return list.objects.map((item) => ({
key: item.key,
key: item.key.replace(this.getKey(""), ""),
size: item.size,
last_modified: item.uploaded,
}));
}
private async headObject(key: string): Promise<R2Object | null> {
return await this.bucket.head(key);
return await this.bucket.head(this.getKey(key));
}
async objectExists(key: string): Promise<boolean> {
return (await this.headObject(key)) !== null;
}
async getObject(key: string, headers: Headers): Promise<Response> {
async getObject(_key: string, headers: Headers): Promise<Response> {
let object: R2ObjectBody | null;
const key = this.getKey(_key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": guess(key),
});
const range = headers.has("range");
//console.log("getObject:headers", headersToObject(headers));
if (headers.has("range")) {
if (range) {
const options = isDebug()
? {} // miniflare doesn't support range requests
: {
range: headers,
onlyIf: headers,
};
object = (await this.bucket.get(key, options)) as R2ObjectBody;
if (!object) {
@@ -122,21 +128,20 @@ export class StorageR2Adapter extends StorageAdapter {
}
}
//console.log("response headers:before", headersToObject(responseHeaders));
this.writeHttpMetadata(responseHeaders, object);
responseHeaders.set("etag", object.httpEtag);
responseHeaders.set("Content-Length", String(object.size));
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
//console.log("response headers:after", headersToObject(responseHeaders));
return new Response(object.body, {
status: object.range ? 206 : 200,
status: range ? 206 : 200,
headers: responseHeaders,
});
}
private writeHttpMetadata(headers: Headers, object: R2Object | R2ObjectBody): void {
let metadata = object.httpMetadata;
if (!metadata || Object.keys(metadata).length === 0) {
// guessing is especially required for dev environment (miniflare)
metadata = {
@@ -163,13 +168,17 @@ export class StorageR2Adapter extends StorageAdapter {
}
async deleteObject(key: string): Promise<void> {
await this.bucket.delete(key);
await this.bucket.delete(this.getKey(key));
}
getObjectUrl(key: string): string {
throw new Error("Method getObjectUrl not implemented.");
}
protected getKey(key: string) {
return key;
}
toJSON(secrets?: boolean) {
return {
type: this.getName(),
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import path from "node:path";
import { describe, afterAll, test, expect } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => {
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;
test("test", () => {
expect(bucket).toBeDefined();
});
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(viTestRunner, adapter, file);
});
afterAll(async () => {
await mf?.dispose();
});
@@ -0,0 +1,14 @@
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
miniflare: {
compatibilityDate: "2025-06-04",
},
},
},
include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
},
});
+19 -1
View File
@@ -1,7 +1,11 @@
import { App, type CreateAppConfig } from "bknd";
import { config as $config } from "bknd/core";
import { $console } from "bknd/utils";
import type { MiddlewareHandler } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController";
import { Connection } from "bknd/data";
export { Connection } from "bknd/data";
export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
@@ -59,7 +63,21 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
app = App.create(makeConfig(config, args));
const appConfig = makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = config.connection ?? { url: ":memory:" };
connection = sqlite(conf);
$console.info(`Using ${connection.name} connection`, conf.url);
}
appConfig.connection = connection;
}
app = App.create(appConfig);
apps.set(id, app);
}
return app;
-1
View File
@@ -3,7 +3,6 @@ import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] };
};
@@ -0,0 +1,57 @@
import { genericSqlite } from "bknd/data";
import { DatabaseSync } from "node:sqlite";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
let db: DatabaseSync;
if (config) {
if ("database" in config) {
db = config.database;
} else {
db = new DatabaseSync(config.url);
}
} else {
db = new DatabaseSync(":memory:");
}
return genericSqlite(
"node-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
run: (sql, parameters = []) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
iterator: (isSelect, sql, parameters = []) => {
if (!isSelect) {
throw new Error("Only support select in stream()");
}
return getStmt(sql).iterate(...parameters) as any;
},
};
},
{
supports: {
batching: false,
},
},
);
}
@@ -0,0 +1,15 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe } from "vitest";
import { viTestRunner } from "../vitest";
describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, {
makeConnection: () => ({
connection: nodeSqlite({ database: new DatabaseSync(":memory:") }),
dispose: async () => {},
}),
rawDialectDetails: [],
});
});
+2 -18
View File
@@ -1,19 +1,3 @@
import { registries } from "bknd";
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export { nodeTestRunner } from "./test";
let registered = false;
export function registerLocalMediaAdapter() {
if (!registered) {
registries.media.register("local", StorageLocalAdapter);
registered = true;
}
return (config: Partial<LocalAdapterConfig> = {}) => {
const adapter = new StorageLocalAdapter(config);
return adapter.toJSON(true);
};
}
export * from "./storage";
export * from "./connection/NodeSqliteConnection";
+3 -3
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as node from "./node.adapter";
import { createApp, createHandler } from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
@@ -9,7 +9,7 @@ afterAll(enableConsoleLog);
describe("node adapter (bun)", () => {
adapterTestSuite(bunTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
makeApp: createApp,
makeHandler: createHandler,
});
});
+8 -3
View File
@@ -1,9 +1,11 @@
import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/index";
import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core";
import { $console } from "core/utils";
import type { App } from "App";
type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
@@ -44,8 +46,11 @@ export function createHandler<Env = NodeEnv>(
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
let app: App | undefined;
return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts);
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
}
return app.fetch(req);
};
}
@@ -62,7 +67,7 @@ export function serve<Env = NodeEnv>(
fetch: createHandler(config, args, opts),
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
$console.log(`Server is running on http://localhost:${connInfo.port}`);
listener?.(connInfo);
},
);
@@ -1,14 +1,14 @@
import { describe, before, after } from "node:test";
import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { nodeTestRunner } from "adapter/node";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog());
after(enableConsoleLog);
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(nodeTestRunner, {
adapterTestSuite(viTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
});
@@ -1,7 +1,9 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, isFile, parse } from "bknd/utils";
import { type Static, isFile, parse } from "bknd/utils";
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
import * as tb from "@sinclair/typebox";
const { Type } = tb;
export const localAdapterConfig = Type.Object(
{
@@ -1,5 +1,6 @@
import { describe } from "node:test";
import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
import { describe } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
import path from "node:path";
@@ -13,5 +14,5 @@ describe("StorageLocalAdapter (node)", async () => {
path: path.join(basePath, "tmp"),
});
await adapterTestSuite(nodeTestRunner, adapter, file);
await adapterTestSuite(viTestRunner, adapter, file);
});
+17
View File
@@ -0,0 +1,17 @@
import { registries } from "bknd";
import { type LocalAdapterConfig, StorageLocalAdapter } from "./StorageLocalAdapter";
export * from "./StorageLocalAdapter";
let registered = false;
export function registerLocalMediaAdapter() {
if (!registered) {
registries.media.register("local", StorageLocalAdapter);
registered = true;
}
return (config: Partial<LocalAdapterConfig> = {}) => {
const adapter = new StorageLocalAdapter(config);
return adapter.toJSON(true);
};
}
+5 -1
View File
@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict";
import { test } from "node:test";
import { test, describe, beforeEach, afterEach } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
@@ -85,6 +85,7 @@ nodeTest.skipIf = (condition: boolean): Test => {
};
export const nodeTestRunner: TestRunner = {
describe,
test: nodeTest,
mock: createMockFunction,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
@@ -96,4 +97,7 @@ export const nodeTestRunner: TestRunner = {
reject: (r) => nodeTestMatcher(r, failMsg),
}),
}),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: () => {},
};
+53
View File
@@ -0,0 +1,53 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
}
vitestTest.if = (condition: boolean): Test => {
if (condition) {
return vitestTest;
}
return (() => {}) as any;
};
vitestTest.skip = (label: string, fn: TestFn) => {
return test.skip(label, fn as any);
};
vitestTest.skipIf = (condition: boolean): Test => {
if (condition) {
return (() => {}) as any;
}
return vitestTest;
};
const vitestExpect = <T = unknown>(actual: T, parentFailMsg?: string) => {
return {
toEqual: (expected: T, failMsg = parentFailMsg) => {
expect(actual, failMsg).toEqual(expected);
},
toBe: (expected: T, failMsg = parentFailMsg) => {
expect(actual, failMsg).toBe(expected);
},
toBeString: () => expect(typeof actual, parentFailMsg).toBe("string"),
toBeUndefined: () => expect(actual, parentFailMsg).toBeUndefined(),
toBeDefined: () => expect(actual, parentFailMsg).toBeDefined(),
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
const e = Array.isArray(expected) ? expected : [expected];
expect(actual, failMsg).toBeOneOf(e);
},
toHaveBeenCalled: () => expect(actual, parentFailMsg).toHaveBeenCalled(),
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
expect(actual, failMsg).toHaveBeenCalledTimes(expected);
},
};
};
export const viTestRunner: TestRunner = {
describe,
test: vitestTest,
expect: vitestExpect as any,
mock: (fn) => vi.fn(fn),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: afterAll,
};
+6
View File
@@ -0,0 +1,6 @@
import type { Connection } from "bknd/data";
import { bunSqlite } from "../bun/connection/BunSqliteConnection";
export function sqlite(config?: { url: string }): Connection {
return bunSqlite(config);
}
+5
View File
@@ -0,0 +1,5 @@
import { type Connection, libsql } from "bknd/data";
export function sqlite(config: { url: string }): Connection {
return libsql(config);
}
+6
View File
@@ -0,0 +1,6 @@
import type { Connection } from "bknd/data";
import { nodeSqlite } from "../node/connection/NodeSqliteConnection";
export function sqlite(config?: { url: string }): Connection {
return nodeSqlite(config);
}
+11 -15
View File
@@ -1,24 +1,17 @@
import { serveStatic } from "@hono/node-server/serve-static";
import {
type DevServerOptions,
default as honoViteDevServer,
} from "@hono/vite-dev-server";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import type { App } from "bknd";
import {
type RuntimeBkndConfig,
createRuntimeApp,
type FrameworkOptions,
} from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono";
export type ViteEnv = NodeJS.ProcessEnv;
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {
serveStatic?: false | MiddlewareHandler;
};
export function addViteScript(
html: string,
addBkndContext: boolean = true,
) {
export function addViteScript(html: string, addBkndContext: boolean = true) {
return html.replace(
"</head>",
`<script type="module">
@@ -48,7 +41,10 @@ async function createApp<ViteEnv>(
mainPath: "/src/main.tsx",
},
},
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
serveStatic: config.serveStatic || [
"/assets/*",
serveStatic({ root: config.distPath ?? "./" }),
],
},
env,
opts,
+20 -151
View File
@@ -1,29 +1,24 @@
import {
type AuthAction,
AuthPermissions,
Authenticator,
type ProfileExchange,
Role,
type Strategy,
} from "auth";
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { $console, type DB, Exception, type PrimaryFieldType } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils";
import type { DB } from "core";
import { $console, secureRandomString, transformObject } from "core/utils";
import type { Entity, EntityManager } from "data";
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
import { pick } from "lodash-es";
import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { Module } from "modules/Module";
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";
import { usersFields } from "./auth-entities";
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "core" {
interface Users extends AppEntity, UserFieldSchema {}
interface DB {
users: { id: PrimaryFieldType } & UserFieldSchema;
users: Users;
}
}
type AuthSchema = Static<typeof authConfigSchema>;
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
export class AppAuth extends Module<typeof authConfigSchema> {
@@ -31,12 +26,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
cache: Record<string, any> = {};
_controller!: AuthController;
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) {
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
if (!from.enabled && to.enabled) {
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);
}
}
@@ -80,7 +75,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,
cookie: this.config.cookie,
});
@@ -90,7 +85,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
this._controller = new AuthController(this);
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) {
@@ -122,120 +117,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
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> {
const entity_name = this.config.entity_name;
if (forceCreate || !this.em.hasEntity(entity_name)) {
@@ -245,22 +126,11 @@ export class AppAuth extends Module<typeof authConfigSchema> {
return this.em.entity(entity_name) as any;
}
static usersFields = {
email: text().required(),
strategy: text({
fillable: ["create"],
hidden: ["update", "form"],
}).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"],
}).required(),
role: text(),
};
static usersFields = usersFields;
registerEntities() {
const users = this.getUsersEntity(true);
this.ensureSchema(
this.ctx.helper.ensureSchema(
em(
{
[users.name as "users"]: users,
@@ -273,13 +143,13 @@ export class AppAuth extends Module<typeof authConfigSchema> {
try {
const roles = Object.keys(this.config.roles ?? {});
this.replaceEntityField(users, "role", enumm({ enum: roles }));
this.ctx.helper.replaceEntityField(users, "role", enumm({ enum: roles }));
} catch (e) {}
try {
// also keep disabled strategies as a choice
const strategies = Object.keys(this.config.strategies ?? {});
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
this.ctx.helper.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
} catch (e) {}
}
@@ -288,7 +158,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
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 strategy_value = await pw.hash(password);
const mutator = this.em.mutator(this.config.entity_name as "users");
@@ -315,8 +185,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
...this.authenticator.toJSON(secrets),
strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy),
type: strategy.getType(),
config: strategy.toJSON(secrets),
...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/utils";
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";
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> {
protected override getDefaultOptions(): Partial<AuthApiOptions> {
return {
basepath: "/api/auth",
credentials: "include",
};
}
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include",
credentials: this.options.credentials,
});
if (res.ok && res.body.token) {
@@ -27,7 +29,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include",
credentials: this.options.credentials,
});
if (res.ok && res.body.token) {
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
}
async logout() {}
async logout() {
await this.options.onTokenUpdate?.(undefined);
}
}
+67 -38
View File
@@ -1,9 +1,9 @@
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
import { tbValidator as tb } from "core";
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
import { TypeInvalidError, parse, transformObject } from "core/utils";
import { DataPermissions } from "data";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s } from "core/object/schema";
export type AuthActionResponse = {
success: boolean;
@@ -12,10 +12,6 @@ export type AuthActionResponse = {
errors?: any;
};
const booleanLike = Type.Transform(Type.String())
.Decode((v) => v === "1")
.Encode((v) => (v ? "1" : "0"));
export class AuthController extends Controller {
constructor(private auth: AppAuth) {
super();
@@ -54,6 +50,10 @@ export class AuthController extends Controller {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
}),
async (c) => {
try {
const body = await this.auth.authenticator.getBody(c);
@@ -91,9 +91,16 @@ export class AuthController extends Controller {
}
},
);
hono.get("create/schema.json", async (c) => {
return c.json(create.schema);
});
hono.get(
"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);
@@ -102,42 +109,55 @@ export class AuthController extends Controller {
override getController() {
const { auth } = this.middlewares;
const hono = this.create();
const strategies = this.auth.authenticator.getStrategies();
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.get(
"/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);
await this.auth.authenticator?.requestCookieRefresh(c);
return c.json({ user });
}
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
this.registerStrategyActions(strategy, hono);
}
return c.json({ user: null }, 403);
},
);
hono.get("/me", 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.get(
"/logout",
describeRoute({
summary: "Logout the current 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) => {
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("/");
});
return c.redirect("/");
},
);
hono.get(
"/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) => {
const { include_disabled } = c.req.valid("query");
const { strategies, basepath } = this.auth.toJSON(false);
@@ -155,6 +175,15 @@ export class AuthController extends Controller {
},
);
return hono.all("*", (c) => c.notFound());
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;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { text } from "data/prototype";
export const usersFields = {
email: text().required(),
strategy: text({
fillable: ["create"],
hidden: ["update", "form"],
}).required(),
strategy_value: text({
fillable: ["create"],
hidden: ["read", "table", "update", "form"],
}).required(),
role: text(),
};
+3 -1
View File
@@ -1,6 +1,8 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
import { type Static, StringRecord, objectTransform } from "core/utils";
import * as tbbox from "@sinclair/typebox";
const { Type } = tbbox;
export const Strategies = {
password: {
+145 -85
View File
@@ -1,13 +1,13 @@
import { type DB, Exception, type PrimaryFieldType } from "core";
import { type DB, Exception } from "core";
import { addFlashMessage } from "core/server/flash";
import {
$console,
type Static,
StringEnum,
type TObject,
Type,
parse,
runtimeSupports,
transformObject,
truncate,
} from "core/utils";
import type { Context, Hono } from "hono";
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
@@ -15,6 +15,9 @@ import { sign, verify } from "hono/jwt";
import type { CookieOptions } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
import * as tbbox from "@sinclair/typebox";
import { InvalidConditionsException } from "auth/errors";
const { Type } = tbbox;
type Input = any; // workaround
export type JWTPayload = Parameters<typeof sign>[0];
@@ -23,11 +26,12 @@ export const strategyActions = ["create", "change"] as const;
export type StrategyActionName = (typeof strategyActions)[number];
export type StrategyAction<S extends TObject = TObject> = {
schema: S;
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
};
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
// @todo: add schema to interface to ensure proper inference
// @todo: add tests (e.g. invalid strategy_value)
export interface Strategy {
getController: (auth: Authenticator) => Hono<any>;
getType: () => string;
@@ -37,28 +41,22 @@ export interface Strategy {
getActions?: () => StrategyActions;
}
export type User = {
id: PrimaryFieldType;
email: string;
password: string;
role?: string | null;
};
export type User = DB["users"];
export type ProfileExchange = {
email?: string;
username?: string;
sub?: string;
password?: string;
strategy?: string;
strategy_value?: string;
[key: string]: any;
};
export type SafeUser = Omit<User, "password">;
export type SafeUser = Omit<User, "strategy_value">;
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
export type AuthResponse = { user: SafeUser; token: string };
export interface UserPool<Fields = "id" | "email" | "username"> {
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
create: (user: CreateUser) => Promise<User | undefined>;
export interface UserPool {
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>;
create: (strategy: string, user: CreateUser) => Promise<User>;
}
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
@@ -100,12 +98,17 @@ export const authenticatorConfig = Type.Object({
type AuthConfig = Static<typeof authenticatorConfig>;
export type AuthAction = "login" | "register";
export type AuthResolveOptions = {
identifier?: "email" | string;
redirect?: string;
forceJsonResponse?: boolean;
};
export type AuthUserResolver = (
action: AuthAction,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
) => Promise<SafeUser | undefined>;
opts?: AuthResolveOptions,
) => Promise<ProfileExchange | undefined>;
type AuthClaims = SafeUser & {
iat: number;
iss?: string;
@@ -113,33 +116,117 @@ type AuthClaims = SafeUser & {
};
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies;
private readonly config: AuthConfig;
private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
this.strategies = strategies as Strategies;
constructor(
private readonly strategies: Strategies,
private readonly userPool: UserPool,
config?: AuthConfig,
) {
this.config = parse(authenticatorConfig, config ?? {});
}
async resolve(
action: AuthAction,
async resolveLogin(
c: Context,
strategy: Strategy,
identifier: string,
profile: ProfileExchange,
): Promise<AuthResponse> {
//console.log("resolve", { action, strategy: strategy.getName(), profile });
const user = await this.userResolver(action, strategy, identifier, profile);
profile: Partial<SafeUser>,
verify: (user: User) => Promise<void>,
opts?: AuthResolveOptions,
) {
try {
// @todo: centralize identifier and checks
// @todo: check identifier value (if allowed)
const identifier = opts?.identifier || "email";
if (typeof identifier !== "string" || identifier.length === 0) {
throw new InvalidConditionsException("Identifier must be a string");
}
if (!(identifier in profile)) {
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
}
if (user) {
return {
user,
token: await this.jwt(user),
};
const user = await this.userPool.findBy(
strategy.getName(),
identifier as any,
profile[identifier],
);
if (!user.strategy_value) {
throw new InvalidConditionsException("User must have a strategy value");
} else if (user.strategy !== strategy.getName()) {
throw new InvalidConditionsException("User signed up with a different strategy");
}
await verify(user);
const data = await this.safeAuthResponse(user);
return this.respondWithUser(c, data, opts);
} catch (e) {
return this.respondWithError(c, e as Error, opts);
}
}
async resolveRegister(
c: Context,
strategy: Strategy,
profile: CreateUser,
verify: (user: User) => Promise<void>,
opts?: AuthResolveOptions,
) {
try {
const identifier = opts?.identifier || "email";
if (typeof identifier !== "string" || identifier.length === 0) {
throw new InvalidConditionsException("Identifier must be a string");
}
if (!(identifier in profile)) {
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
}
if (!("strategy_value" in profile)) {
throw new InvalidConditionsException("Profile must have a strategy value");
}
const user = await this.userPool.create(strategy.getName(), {
...profile,
strategy_value: profile.strategy_value,
});
await verify(user);
const data = await this.safeAuthResponse(user);
return this.respondWithUser(c, data, opts);
} catch (e) {
return this.respondWithError(c, e as Error, opts);
}
}
private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) {
const successUrl = this.getSafeUrl(
c,
opts?.redirect ?? this.config.cookie.pathSuccess ?? "/",
);
if ("token" in data) {
await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs
return c.redirect(successUrl);
}
throw new Error("User could not be resolved");
throw new Exception("Invalid response");
}
async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) {
$console.error("respondWithError", error);
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
// let the server handle it
throw error;
}
await addFlashMessage(c, String(error), "error");
const referer = this.getSafeUrl(c, opts?.redirect ?? c.req.header("Referer") ?? "/");
return c.redirect(referer);
}
getStrategies(): Strategies {
@@ -158,7 +245,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
// @todo: add jwt tests
async jwt(_user: Omit<User, "password">): Promise<string> {
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
const user = pick(_user, this.config.jwt.fields);
const payload: JWTPayload = {
@@ -184,6 +271,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
}
async safeAuthResponse(_user: User): Promise<AuthResponse> {
const user = pick(_user, this.config.jwt.fields) as SafeUser;
return {
user,
token: await this.jwt(user),
};
}
async verify(jwt: string): Promise<AuthClaims | undefined> {
try {
const payload = await verify(
@@ -225,7 +320,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return token;
} catch (e: any) {
if (e instanceof Error) {
console.error("[Error:getAuthCookie]", e.message);
$console.error("[getAuthCookie]", e.message);
}
return undefined;
@@ -242,27 +337,33 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
}
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
$console.debug("setting auth cookie", truncate(token));
const secret = this.config.jwt.secret;
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
private async deleteAuthCookie(c: Context) {
await deleteCookie(c, "auth", this.cookieOptions);
private deleteAuthCookie(c: Context) {
$console.debug("deleting auth cookie");
deleteCookie(c, "auth", this.cookieOptions);
}
async logout(c: Context<ServerEnv>) {
$console.info("Logging out");
c.set("auth", undefined);
const cookie = await this.getAuthCookie(c);
if (cookie) {
await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info");
addFlashMessage(c, "Signed out", "info");
}
// on waku, only one cookie setting is performed
// therefore adding deleting cookie at the end
// as the flash isn't that important
this.deleteAuthCookie(c);
}
// @todo: move this to a server helper
isJsonRequest(c: Context): boolean {
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
return c.req.header("Content-Type") === "application/json";
}
@@ -286,37 +387,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return p;
}
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) {
await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c)) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl);
}
if (this.isJsonRequest(c)) {
return c.json(data, 400);
}
let message = "An error occured";
if (data instanceof Exception) {
message = data.message;
}
await addFlashMessage(c, message, "error");
//console.log("auth failed, redirecting to", referer);
return c.redirect(referer);
}
// @todo: don't extract user from token, but from the database or cache
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
let token: string | undefined;
@@ -341,13 +411,3 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
};
}
}
export function createStrategyAction<S extends TObject>(
schema: S,
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>,
) {
return {
schema,
preprocess,
} as StrategyAction<S>;
}
@@ -1,152 +1,135 @@
import type { Authenticator, Strategy } from "auth";
import { isDebug, tbValidator as tb } from "core";
import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils";
import { type Context, Hono } from "hono";
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
import { tbValidator as tb } from "core";
import { $console, hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import * as tbbox from "@sinclair/typebox";
import { Strategy } from "./Strategy";
type LoginSchema = { username: string; password: string } | { email: string; password: string };
type RegisterSchema = { email: string; password: string; [key: string]: any };
const { Type } = tbbox;
const schema = Type.Object({
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }),
const schema = StrictObject({
hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }),
rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
});
export type PasswordStrategyOptions = Static<typeof schema>;
/*export type PasswordStrategyOptions2 = {
hashing?: "plain" | "bcrypt" | "sha256";
};*/
export class PasswordStrategy implements Strategy {
private options: PasswordStrategyOptions;
export class PasswordStrategy extends Strategy<typeof schema> {
constructor(config: Partial<PasswordStrategyOptions> = {}) {
super(config as any, "password", "password", "form");
constructor(options: Partial<PasswordStrategyOptions> = {}) {
this.options = parse(schema, options);
}
async hash(password: string) {
switch (this.options.hashing) {
case "sha256":
return hash.sha256(password);
default:
return password;
}
}
async login(input: LoginSchema) {
if (!("email" in input) || !("password" in input)) {
throw new Error("Invalid input: Email and password must be provided");
}
const hashedPassword = await this.hash(input.password);
return { ...input, password: hashedPassword };
}
async register(input: RegisterSchema) {
if (!input.email || !input.password) {
throw new Error("Invalid input: Email and password must be provided");
}
return {
...input,
password: await this.hash(input.password),
};
}
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
return hono
.post(
"/login",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String()),
}),
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
try {
const payload = await this.login(body);
const data = await authenticator.resolve(
"login",
this,
payload.password,
payload,
);
return await authenticator.respond(c, data, redirect);
} catch (e) {
return await authenticator.respond(c, e);
}
},
)
.post(
"/register",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String()),
}),
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
const payload = await this.register(body);
const data = await authenticator.resolve(
"register",
this,
payload.password,
payload,
);
return await authenticator.respond(c, data, redirect);
},
);
}
getActions(): StrategyActions {
return {
create: createStrategyAction(
Type.Object({
email: Type.String({
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
minLength: 8, // @todo: this should be configurable
}),
}),
async ({ password, ...input }) => {
return {
...input,
strategy_value: await this.hash(password),
};
},
),
};
this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => {
return {
...input,
strategy_value: await this.hash(password),
};
});
}
getSchema() {
return schema;
}
getType() {
return "password";
private getPayloadSchema() {
return Type.Object({
email: Type.String({
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
}),
password: Type.String({
minLength: 8, // @todo: this should be configurable
}),
});
}
getMode() {
return "form" as const;
async hash(password: string) {
switch (this.config.hashing) {
case "sha256":
return hash.sha256(password);
case "bcrypt": {
const salt = await bcryptGenSalt(this.config.rounds ?? 4);
return bcryptHash(password, salt);
}
default:
return password;
}
}
getName() {
return "password" as const;
async compare(actual: string, compare: string): Promise<boolean> {
switch (this.config.hashing) {
case "sha256": {
const compareHashed = await this.hash(compare);
return actual === compareHashed;
}
case "bcrypt":
return await bcryptCompare(compare, actual);
}
return false;
}
toJSON(secrets?: boolean) {
return secrets ? this.options : undefined;
verify(password: string) {
return async (user: User) => {
const compare = await this.compare(user?.strategy_value!, password);
if (compare !== true) {
throw new InvalidCredentialsException();
}
};
}
getController(authenticator: Authenticator): Hono<any> {
const hono = new Hono();
const redirectQuerySchema = Type.Object({
redirect: Type.Optional(Type.String()),
});
const payloadSchema = this.getPayloadSchema();
hono.post("/login", tb("query", redirectQuerySchema), async (c) => {
try {
const body = parse(payloadSchema, await authenticator.getBody(c), {
onError: (errors) => {
$console.error("Invalid login payload", [...errors]);
throw new InvalidCredentialsException();
},
});
const { redirect } = c.req.valid("query");
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
});
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
payloadSchema,
await authenticator.getBody(c),
{
onError: (errors) => {
$console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
},
},
);
const profile = {
...body,
email,
strategy_value: await this.hash(password),
};
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
});
return hono;
}
}
@@ -0,0 +1,63 @@
import type {
Authenticator,
StrategyAction,
StrategyActionName,
StrategyActions,
} from "../Authenticator";
import type { Hono } from "hono";
import type { Static, TSchema } from "@sinclair/typebox";
import { parse, type TObject } from "core/utils";
export type StrategyMode = "form" | "external";
export abstract class Strategy<Schema extends TSchema = TSchema> {
protected actions: StrategyActions = {};
constructor(
protected config: Static<Schema>,
public type: string,
public name: string,
public mode: StrategyMode,
) {
// don't worry about typing, it'll throw if invalid
this.config = parse(this.getSchema(), (config ?? {}) as any) as Static<Schema>;
}
protected registerAction<S extends TObject = TObject>(
name: StrategyActionName,
schema: S,
preprocess: StrategyAction<S>["preprocess"],
): void {
this.actions[name] = {
schema,
preprocess,
} as const;
}
protected abstract getSchema(): Schema;
abstract getController(auth: Authenticator): Hono;
getType(): string {
return this.type;
}
getMode() {
return this.mode;
}
getName(): string {
return this.name;
}
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
return {
type: this.getType(),
config: secrets ? this.config : undefined,
};
}
getActions(): StrategyActions {
return this.actions;
}
}
@@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
export * as issuers from "./oauth/issuers";
export {
PasswordStrategy,
type PasswordStrategyOptions,
PasswordStrategy,
OAuthStrategy,
OAuthCallbackException,
CustomOAuthStrategy,
@@ -1,43 +1,35 @@
import { type Static, StringEnum, Type } from "core/utils";
import { type Static, StrictObject, StringEnum } from "core/utils";
import * as tbbox from "@sinclair/typebox";
import type * as oauth from "oauth4webapi";
import { OAuthStrategy } from "./OAuthStrategy";
const { Type } = tbbox;
type SupportedTypes = "oauth2" | "oidc";
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
const oauthSchemaCustom = Type.Object(
const oauthSchemaCustom = StrictObject(
{
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: Type.String(),
client: Type.Object(
{
client_id: Type.String(),
client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
},
{
additionalProperties: false,
},
),
as: Type.Object(
{
issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())),
scope_separator: Type.Optional(Type.String({ default: " " })),
authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString),
},
{
additionalProperties: false,
},
),
client: StrictObject({
client_id: Type.String(),
client_secret: Type.String(),
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
}),
as: StrictObject({
issuer: Type.String(),
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
scopes_supported: Type.Optional(Type.Array(Type.String())),
scope_separator: Type.Optional(Type.String({ default: " " })),
authorization_endpoint: Type.Optional(UrlString),
token_endpoint: Type.Optional(UrlString),
userinfo_endpoint: Type.Optional(UrlString),
}),
// @todo: profile mapping
},
{ title: "Custom OAuth", additionalProperties: false },
{ title: "Custom OAuth" },
);
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
@@ -62,6 +54,11 @@ export type IssuerConfig<UserInfo = any> = {
};
export class CustomOAuthStrategy extends OAuthStrategy {
constructor(config: OAuthConfigCustom) {
super(config as any);
this.type = "custom_oauth";
}
override getIssuerConfig(): IssuerConfig {
return { ...this.config, profile: async (info) => info } as any;
}
@@ -70,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy {
override getSchema() {
return oauthSchemaCustom;
}
override getType() {
return "custom_oauth";
}
}
@@ -1,10 +1,13 @@
import type { AuthAction, Authenticator, Strategy } from "auth";
import type { AuthAction, Authenticator } from "auth";
import { Exception, isDebug } from "core";
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
import { type Context, Hono } from "hono";
import { getSignedCookie, setSignedCookie } from "hono/cookie";
import * as oauth from "oauth4webapi";
import * as issuers from "./issuers";
import * as tbbox from "@sinclair/typebox";
import { Strategy } from "auth/authenticate/strategies/Strategy";
const { Type } = tbbox;
type ConfiguredIssuers = keyof typeof issuers;
type SupportedTypes = "oauth2" | "oidc";
@@ -13,17 +16,12 @@ type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & O
const schemaProvided = Type.Object(
{
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
client: Type.Object(
{
client_id: Type.String(),
client_secret: Type.String(),
},
{
additionalProperties: false,
},
),
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }),
client: StrictObject({
client_id: Type.String(),
client_secret: Type.String(),
}),
},
{ title: "OAuth" },
);
@@ -71,11 +69,13 @@ export class OAuthCallbackException extends Exception {
}
}
export class OAuthStrategy implements Strategy {
constructor(private _config: OAuthConfig) {}
export class OAuthStrategy extends Strategy<typeof schemaProvided> {
constructor(config: ProvidedOAuthConfig) {
super(config, "oauth", config.name, "external");
}
get config() {
return this._config;
getSchema() {
return schemaProvided;
}
getIssuerConfig(): IssuerConfig {
@@ -103,7 +103,7 @@ export class OAuthStrategy implements Strategy {
type: info.type,
client: {
...info.client,
...this._config.client,
...this.config.client,
},
};
}
@@ -172,8 +172,7 @@ export class OAuthStrategy implements Strategy {
) {
const config = await this.getConfig();
const { client, as, type } = config;
//console.log("config", config);
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
@@ -181,13 +180,9 @@ export class OAuthStrategy implements Strategy {
oauth.expectNoState,
);
if (oauth.isOAuth2Error(parameters)) {
//console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
/*console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);*/
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
@@ -195,13 +190,9 @@ export class OAuthStrategy implements Strategy {
options.redirect_uri,
options.state,
);
//console.log("callback.response", response);
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
@@ -216,20 +207,13 @@ export class OAuthStrategy implements Strategy {
expectedNonce,
);
if (oauth.isOAuth2Error(result)) {
console.log("callback.error", result);
// @todo: Handle OAuth 2.0 response body error
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
}
//console.log("callback.result", result);
const claims = oauth.getValidatedIdTokenClaims(result);
//console.log("callback.IDTokenClaims", claims);
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
//console.log("callback.resultUser", resultUser);
return await config.profile(resultUser, config, claims); // @todo: check claims
}
@@ -240,8 +224,7 @@ export class OAuthStrategy implements Strategy {
) {
const config = await this.getConfig();
const { client, type, as, profile } = config;
console.log("config", { client, as, type });
console.log("callbackParams", callbackParams, options);
const parameters = oauth.validateAuthResponse(
as,
client, // no client_secret required
@@ -249,13 +232,9 @@ export class OAuthStrategy implements Strategy {
oauth.expectNoState,
);
if (oauth.isOAuth2Error(parameters)) {
console.log("callback.error", parameters);
throw new OAuthCallbackException(parameters, "validateAuthResponse");
}
console.log(
"callback.parameters",
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
);
const response = await oauth.authorizationCodeGrantRequest(
as,
client,
@@ -266,9 +245,6 @@ export class OAuthStrategy implements Strategy {
const challenges = oauth.parseWwwAuthenticateChallenges(response);
if (challenges) {
for (const challenge of challenges) {
//console.log("callback.challenge", challenge);
}
// @todo: Handle www-authenticate challenges as needed
throw new OAuthCallbackException(challenges, "www-authenticate");
}
@@ -279,19 +255,15 @@ export class OAuthStrategy implements Strategy {
try {
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) {
console.log("error", result);
throw new Error(); // Handle OAuth 2.0 response body error
}
} catch (e) {
result = (await copy.json()) as any;
console.log("failed", result);
}
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
const user = await res2.json();
console.log("res2", res2, user);
console.log("result", result);
return await config.profile(user, config, result);
}
@@ -301,7 +273,6 @@ export class OAuthStrategy implements Strategy {
): Promise<UserProfile> {
const type = this.getIssuerConfig().type;
console.log("type", type);
switch (type) {
case "oidc":
return await this.oidc(callbackParams, options);
@@ -325,7 +296,6 @@ export class OAuthStrategy implements Strategy {
};
const setState = async (c: Context, config: TState): Promise<void> => {
console.log("--- setting state", config);
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
secure: true,
httpOnly: true,
@@ -356,7 +326,6 @@ export class OAuthStrategy implements Strategy {
const params = new URLSearchParams(url.search);
const state = await getState(c);
console.log("state", state);
// @todo: add config option to determine if state.action is allowed
const redirect_uri =
@@ -369,21 +338,28 @@ export class OAuthStrategy implements Strategy {
state: state.state,
});
try {
const data = await auth.resolve(state.action, this, profile.sub, profile);
console.log("******** RESOLVED ********", data);
const safeProfile = {
email: profile.email,
strategy_value: profile.sub,
} as const;
if (state.mode === "cookie") {
return await auth.respond(c, data, state.redirect);
const verify = async (user) => {
if (user.strategy_value !== profile.sub) {
throw new Exception("Invalid credentials");
}
};
const opts = {
redirect: state.redirect,
forceJsonResponse: state.mode !== "cookie",
} as const;
return c.json(data);
} catch (e) {
if (state.mode === "cookie") {
return await auth.respond(c, e, state.redirect);
}
throw e;
switch (state.action) {
case "login":
return auth.resolveLogin(c, this, safeProfile, verify, opts);
case "register":
return auth.resolveRegister(c, this, safeProfile, verify, opts);
default:
throw new Error("Invalid action");
}
});
@@ -412,10 +388,8 @@ export class OAuthStrategy implements Strategy {
redirect_uri,
state,
});
//console.log("_state", state);
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
console.log("--redirecting to", response.url);
return c.redirect(response.url);
});
@@ -456,28 +430,15 @@ export class OAuthStrategy implements Strategy {
return hono;
}
getType() {
return "oauth";
}
getMode() {
return "external" as const;
}
getName() {
return this.config.name;
}
getSchema() {
return schemaProvided;
}
toJSON(secrets?: boolean) {
override toJSON(secrets?: boolean) {
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
return {
type: this.getIssuerConfig().type,
...config,
...super.toJSON(secrets),
config: {
...config,
type: this.getIssuerConfig().type,
},
};
}
}
@@ -34,8 +34,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
config: Omit<IssuerConfig, "profile">,
tokenResponse: any,
) => {
console.log("github info", info, config, tokenResponse);
try {
const res = await fetch("https://api.github.com/user/emails", {
headers: {
@@ -45,7 +43,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
},
});
const data = (await res.json()) as GithubUserEmailResponse;
console.log("data", data);
const email = data.find((e: any) => e.primary)?.email;
if (!email) {
throw new Error("No primary email found");
+22 -20
View File
@@ -1,5 +1,5 @@
import { Exception, Permission } from "core";
import { objectTransform } from "core/utils";
import { $console, objectTransform } from "core/utils";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
import { Role } from "./Role";
@@ -14,8 +14,6 @@ export type GuardConfig = {
};
export type GuardContext = Context<ServerEnv> | GuardUserContext;
const debug = false;
export class Guard {
permissions: Permission[];
roles?: Role[];
@@ -83,8 +81,12 @@ export class Guard {
return this;
}
registerPermissions(permissions: Permission[]) {
for (const permission of permissions) {
registerPermissions(permissions: Record<string, Permission>);
registerPermissions(permissions: Permission[]);
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
this.registerPermission(permission);
}
@@ -95,16 +97,14 @@ export class Guard {
if (user && typeof user.role === "string") {
const role = this.roles?.find((role) => role.name === user?.role);
if (role) {
debug && console.log("guard: role found", [user.role]);
$console.debug(`guard: role "${user.role}" found`);
return role;
}
}
debug &&
console.log("guard: role not found", {
user: user,
role: user?.role,
});
$console.debug("guard: role not found", {
user,
});
return this.getDefaultRole();
}
@@ -120,11 +120,14 @@ export class Guard {
hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) {
//console.log("guard not enabled, allowing");
return true;
}
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
$console.debug("guard: checking permission", {
name,
user: { id: user?.id, role: user?.role },
});
const exists = this.permissionExists(name);
if (!exists) {
throw new Error(`Permission ${name} does not exist`);
@@ -133,10 +136,10 @@ export class Guard {
const role = this.getUserRole(user);
if (!role) {
debug && console.log("guard: role not found, denying");
$console.debug("guard: user has no role, denying");
return false;
} else if (role.implicit_allow === true) {
debug && console.log("guard: role implicit allow, allowing");
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true;
}
@@ -144,12 +147,11 @@ export class Guard {
(rolePermission) => rolePermission.permission.name === name,
);
debug &&
console.log("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission,
});
$console.debug("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission,
});
return !!rolePermission;
}
+45 -7
View File
@@ -1,28 +1,66 @@
import { Exception } from "core";
import { Exception, isDebug } from "core";
import { HttpStatus } from "core/utils";
export class UserExistsException extends Exception {
export class AuthException extends Exception {
getSafeErrorAndCode() {
return {
error: "Invalid credentials",
code: HttpStatus.UNAUTHORIZED,
};
}
override toJSON(): any {
if (isDebug()) {
return super.toJSON();
}
return {
error: this.getSafeErrorAndCode().error,
type: "AuthException",
};
}
}
export class UserExistsException extends AuthException {
override name = "UserExistsException";
override code = 422;
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor() {
super("User already exists");
}
}
export class UserNotFoundException extends Exception {
export class UserNotFoundException extends AuthException {
override name = "UserNotFoundException";
override code = 404;
override code = HttpStatus.NOT_FOUND;
constructor() {
super("User not found");
}
}
export class InvalidCredentialsException extends Exception {
export class InvalidCredentialsException extends AuthException {
override name = "InvalidCredentialsException";
override code = 401;
override code = HttpStatus.UNAUTHORIZED;
constructor() {
super("Invalid credentials");
}
}
export class UnableToCreateUserException extends AuthException {
override name = "UnableToCreateUserException";
override code = HttpStatus.INTERNAL_SERVER_ERROR;
constructor() {
super("Unable to create user");
}
}
export class InvalidConditionsException extends AuthException {
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor(message: string) {
super(message ?? "Invalid conditions");
}
}
-1
View File
@@ -1,5 +1,4 @@
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
export { sha256 } from "./utils/hash";
export {
type ProfileExchange,
type Strategy,

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