Compare commits

..

256 Commits

Author SHA1 Message Date
dswbx f5f591fc4a dev: add main inject plugin 2025-12-05 12:48:32 +01:00
dswbx 6bbc922710 feat: init dev command 2025-12-03 17:46:02 +01:00
dswbx 959dee013e fix admin local auth 2025-12-03 17:41:25 +01:00
dswbx a39e76a440 Merge pull request #315 from bknd-io/feat/admin-local-auth
feat: add local auth support if api storage provided
2025-12-02 19:13:07 +01:00
dswbx acc10377ca feat: add local auth support if api storage provided 2025-12-02 18:18:45 +01:00
dswbx 506c7d84cc chore: bump version to 0.20.0-rc.1 and fix client context 2025-12-02 15:14:27 +01:00
dswbx 7ba542c040 removed sqlocal package, updated readme 2025-12-02 14:21:30 +01:00
dswbx b784d8611a Merge pull request #308 from bknd-io/feat/opfs-and-sqlocal
feat: opfs and sqlocal
2025-12-02 14:18:55 +01:00
dswbx a21d4ad6a7 docs: update sqlocal doc 2025-12-02 14:16:27 +01:00
dswbx 731b7a7e8d tests: fix node tests + add connection test suite for sqlocal 2025-12-02 14:11:26 +01:00
dswbx e56fc9c368 finalized sqlocal, added BkndBrowserApp, updated react example 2025-12-02 14:03:41 +01:00
dswbx d1aa2da5b1 Merge remote-tracking branch 'origin/release/0.20' into feat/opfs-and-sqlocal 2025-12-02 10:31:39 +01:00
dswbx 52fe9aa085 Merge pull request #289 from bknd-io/feat/postgres-fc
feat: move postgres as part of the main repo
2025-12-02 10:29:43 +01:00
dswbx 065821d9a5 chore: github tests with isolated deps 2025-12-02 10:25:49 +01:00
dswbx 0fc817382a Merge remote-tracking branch 'origin/release/0.20' into feat/postgres-fc
# Conflicts:
#	app/src/core/utils/runtime.ts
2025-12-02 10:20:58 +01:00
dswbx b2872eb196 Merge pull request #313 from bknd-io/feat/data-implicit-joins
feat: Add implicit joins in repository where clauses
2025-12-02 09:49:44 +01:00
dswbx 3d804f5ac5 Merge pull request #312 from bknd-io/fix/controller-schema-access
fix: putting schema related endpoints behind schema permission and added tests
2025-12-02 09:49:25 +01:00
dswbx da025efee1 Merge pull request #307 from bknd-io/fix/ui-client-import
Fix UI client import
2025-12-02 09:48:35 +01:00
dswbx ab1fa4c895 fix: add infinite back to dropzone container 2025-12-02 09:46:09 +01:00
dswbx ebe3bc2ff5 Merge pull request #311 from bknd-io/fix/admin-field-config
fix config reconciliation for specific field types, remove lodash
2025-12-02 09:43:58 +01:00
dswbx f792d8b93e feat(repository): add implicit joins in where clauses 2025-12-02 09:38:16 +01:00
dswbx 319469f44b fix: putting schema related endpoints behind schema permission and add tests 2025-12-02 08:53:49 +01:00
dswbx 1359741e5f fix config reconciliation for specific field types, remove lodash
Replace lodash with native utilities and fix config merging to preserve common properties when switching between field types.
2025-11-29 14:18:57 +01:00
dswbx 8f4de33a76 use nullish coalescing and fix mode plugin pick up 2025-11-26 10:04:24 +01:00
dswbx 43dbc856ce fix SystemController config read permission 2025-11-26 07:41:19 +01:00
dswbx 5a8f2b4894 upgrade bun and set linker isolated 2025-11-25 16:28:11 +01:00
dswbx 2627213de7 Merge pull request #292 from bknd-io/feat/add-otp-plugin
OTP plugin
2025-11-25 16:24:20 +01:00
dswbx 36e1bb1867 init opfs and sqlocal as another browser adapter 2025-11-25 16:21:16 +01:00
dswbx 0cf1d86213 fix client imports to prevent multiple react context's 2025-11-25 16:16:10 +01:00
dswbx 3fac740771 Merge branch 'release/0.20' into feat/add-otp-plugin 2025-11-21 20:03:30 +01:00
dswbx 5e5dc62304 Merge pull request #299 from bknd-io/fix/rec-with-join
fix: recursive `with` omitted join clauses
2025-11-21 20:01:46 +01:00
dswbx c1d6384271 Merge pull request #301 from bknd-io/fix/cli-run-open
fix cli url opener and add minimal debug docker config
2025-11-21 19:40:54 +01:00
dswbx f2bd04770a Merge pull request #303 from bknd-io/feat/cf-vite-templates
Add Cloudflare Vite templates for fullstack deployments
2025-11-21 19:39:29 +01:00
dswbx 41d43126cb ignore generated worker-configuration.d.ts files 2025-11-21 18:29:13 +01:00
dswbx a39407c9a3 update cloudflare-vite-code readme to reflect code-only mode
The template documentation now emphasizes code-first approach with manual type management instead of automatic file generation. Includes updated configuration examples, deployment instructions, and a new comparison table between code and hybrid modes.
2025-11-21 18:18:56 +01:00
dswbx 5d6046b149 template: added cloudflare vite code template 2025-11-21 17:55:43 +01:00
dswbx 000f8d20a8 template: added cloudflare vite hybrid template 2025-11-21 17:55:24 +01:00
dswbx 9e7547e787 Merge pull request #302 from bknd-io/fix/cf-proxy-and-modes
Fix: cf proxy and modes
2025-11-21 17:54:23 +01:00
dswbx c3ae4a3999 fix cloudflare adapter warm mode and type improvements
Adds warm mode support to cloudflare adapter to enable app instance caching. Improves BkndConfig type handling and makes hybrid mode reader optional. Fixes entity hook types to properly handle Generated ID types from kysely.
2025-11-21 17:35:45 +01:00
dswbx 5c3eeb7642 fix json schema validation initialization
ensure `getJsonSchema` handles both object and non-object outputs to prevent errors during validation initialization. this improves robustness when handling edge cases in schema configurations.
2025-11-20 21:11:28 +01:00
dswbx a2fa11ccd0 refactor modes implementation and improve validation handling
refactor `code` and `hybrid` modes for better type safety and configuration flexibility. add `_isProd` helper to standardize environment checks and improve plugin syncing warnings. adjust validation logic for clean JSON schema handling and enhance test coverage for modes.
2025-11-20 21:08:16 +01:00
dswbx fdee39da62 fix cli url opener and add minimal debug docker config 2025-11-20 20:04:13 +01:00
dswbx 16a3a67634 fix: recursive with omitted join clauses 2025-11-14 22:17:27 +01:00
dswbx 2b5e1771de refactor: enhance email OTP responses and improve data structure
Updated the email OTP plugin to return a structured response containing the email, action, and expiration details. Adjusted the tests to validate the new response format. Increased password length for user creation
2025-11-14 21:59:06 +01:00
dswbx a16e017e39 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-14 09:01:36 +01:00
dswbx 079d613b2e adjust biome configuration for improved linting and file inclusion
add specific file exclusions in `biome.json` to refine file tracking and adapt linting script in `package.json` to automatically write changes.
2025-11-11 16:42:19 +01:00
dswbx ff56d616d9 chore: upgrade bun to 1.3.2 2025-11-11 15:57:24 +01:00
dswbx 5b3d36c527 Merge pull request #294 from cameronapak/claude/add-loops-email-driver-011CUxMpVqe8AT22gN2k5ZVm
Email Driver: Add Plunk Support
2025-11-11 15:48:18 +01:00
cameronapak 2885fea077 Remove multi-recipient support from Plunk driver 2025-11-11 08:30:26 -06:00
dswbx 5ce22162c3 Merge pull request #297 from jonaspm/patch-1
Update import path for getApi in astro.mdx
2025-11-11 13:56:24 +01:00
dswbx 3f55c0f2c5 fix imports and adjust biome and linting configuration
remove unused `SecretSchema` import, adjust biome settings to modify linting behavior, and clean formatting in `slugify` and other functions.
2025-11-11 13:29:24 +01:00
Jonás Perusquía Morales 21f642161d Update import path for getApi in astro.mdx
Fix import path for getApi.
2025-11-10 18:42:49 -06:00
dswbx 7912729159 Merge pull request #296 from jonaspm/patch-1
Update Astro integration documentation
2025-11-10 19:36:16 +01:00
Jonás Perusquía Morales d3e87be604 Update Astro integration documentation
Removed note about Astro v4 and issues with v5. Issue was fixed on Dec 16, 2024 [https://github.com/withastro/astro/issues/12699]
2025-11-10 12:02:22 -06:00
dswbx ba3d11edab Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-10 13:25:01 +01:00
dswbx 9ec31373e1 Merge pull request #295 from bknd-io/fix/hybrid-schema-sync
fix hybrid schema sync
2025-11-10 10:36:52 +01:00
dswbx 70b25a9f9b hybrid: fix timing for automatic schema syncs to work with plugins 2025-11-10 10:34:50 +01:00
dswbx b6717f0237 otp: update docs on permissions, only require email driver if sendEmail is not false 2025-11-10 10:25:33 +01:00
dswbx c57f3e8070 otp: added missing tests 2025-11-10 10:17:16 +01:00
dswbx c2f4f92d1a refactor otp listeners 2025-11-10 09:40:47 +01:00
dswbx 6eb8525656 otp: add sendEmail option to disable sending for debugging 2025-11-10 09:30:24 +01:00
cameronapak e2ebb57564 Fixed tests :) 2025-11-09 08:11:49 -06:00
cameronapak 2b7bbc5df0 Update tests to use real information 2025-11-09 08:01:14 -06:00
Claude 8766b742c6 add PLUNK_API_KEY to .env.example 2025-11-09 13:50:12 +00:00
Claude 3befa43a9e remove PRD and task list documents from PR 2025-11-09 13:42:08 +00:00
Claude a842808464 mark all tasks as completed in task list 2025-11-09 13:36:18 +00:00
Claude bd1ef8ed57 implement Plunk email driver with comprehensive tests
- Add plunkEmail driver following IEmailDriver interface
- Support single and multiple recipients (up to 5 max per Plunk API)
- Handle both string and { text, html } body formats
- Include sender customization (from, name, reply)
- Add recipient validation and error handling
- Create comprehensive unit tests with 6 test cases
- Export plunkEmail from core drivers index
- Update task list with implementation progress
2025-11-09 13:35:48 +00:00
Claude a0019e5500 add detailed sub-tasks for Plunk email driver implementation 2025-11-09 13:29:42 +00:00
Claude 8ec67a5492 add task list for Plunk email driver implementation 2025-11-09 13:28:04 +00:00
Claude 960f7c3fb2 add PRD for Plunk email driver implementation 2025-11-09 13:25:38 +00:00
dswbx 793c214e6d Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:58:48 +01:00
dswbx 4094004b83 Merge remote-tracking branch 'origin/release/0.20' into feat/add-otp-plugin 2025-11-07 09:50:33 +01:00
dswbx 300d86ff7c Merge pull request #293 from bknd-io/chore/biome-upgrade
upgrade biome and vscode settings
2025-11-07 09:49:22 +01:00
dswbx 4c9b662f6f fix bknd/util imports 2025-11-07 09:47:43 +01:00
dswbx a862cfdcf1 add .vscode/settings.json 2025-11-07 09:45:12 +01:00
dswbx de62f4e729 upgrade biome config 2025-11-07 09:39:02 +01:00
dswbx 7e399830e5 add validation for used OTP codes 2025-11-07 09:12:58 +01:00
dswbx ee2ab982df docs: added emailOTP plugin docs 2025-11-07 09:11:13 +01:00
dswbx 5be55a6fa6 clean up error, it's prefixed with the plugin name already 2025-11-07 08:51:44 +01:00
dswbx 341eb13425 rename to email otp to make it more explicit 2025-11-07 08:48:02 +01:00
dswbx 68fbb6e933 fix events reference for instance checks 2025-11-06 20:47:19 +01:00
dswbx 8b36985252 otp: add entity config, reduce result 2025-11-06 20:38:16 +01:00
dswbx ff86240b0e feat: add OTP plugin 2025-11-06 20:06:51 +01:00
dswbx 40bbdb904f refactor: expose additional kysely postgres options 2025-11-05 10:47:47 +01:00
dswbx a333d537b0 refactor postgres functions to not rely on the packages 2025-11-05 10:21:35 +01:00
dswbx 108c108d82 merge origin/release/0.20 2025-11-05 10:05:57 +01:00
dswbx 4575d89cfe Merge pull request #290 from bknd-io/chore/upgrade-deps
upgrade deps
2025-11-05 10:03:36 +01:00
dswbx 0ca0828581 fix examples 2025-11-05 09:37:49 +01:00
dswbx 07a57ebf67 fix cli build script, add commander, fix types 2025-11-05 08:27:47 +01:00
dswbx e9f1241ec3 fix tests: remove .only 2025-11-05 08:20:36 +01:00
dswbx 80903d4ffa upgrade bun, remove hoisting 2025-11-05 08:15:41 +01:00
dswbx c8290802e9 upgrade biome, typescript, icons, other dev deps. also keep hoisting for now 2025-11-05 07:48:33 +01:00
dswbx ac6cd4a900 upgrade vite + fix types 2025-10-31 21:40:55 +01:00
dswbx a91ddb1ec3 update deps 2025-10-31 21:33:11 +01:00
dswbx ed47c5bf51 postgres: move examples up 2025-10-31 20:11:16 +01:00
dswbx dbb19a27f4 fix pg imports for node tests 2025-10-31 17:48:50 +01:00
dswbx 71a17696eb fix ".only" tests 2025-10-31 17:44:37 +01:00
dswbx 2dbafebcea pg test: change is running check approach 2025-10-31 17:38:17 +01:00
dswbx 84d4635ec3 fix bun 1.3 new hoisting (for now) 2025-10-31 17:31:19 +01:00
dswbx e9c92b6086 upgrade bun, add zustand 2025-10-31 17:27:04 +01:00
dswbx 5fa7601ad5 revert bun upgrade (should be separate pr) 2025-10-31 17:21:58 +01:00
dswbx 2c7054c317 feat: move postgres as part of the main repo 2025-10-31 17:13:23 +01:00
dswbx 5417aa174e bump 0.19.0, update readme with use cases, small fixes
- fix password strategy openapi tags
- fix cli run picking up memory if specified
- fix view transition chrome crash
2025-10-31 11:55:27 +01:00
dswbx 6093f4f46f Merge pull request #278 from bknd-io/release/0.19
Release 0.19
2025-10-31 09:27:01 +01:00
dswbx 2178e0ee8b fix CodePreview shiki dynamic load for frameworks like Next.js 2025-10-31 09:24:45 +01:00
dswbx be39e8a391 chore: bump version to 0.19.0-rc.3 and update JsonField handling for improved value parsing 2025-10-28 16:07:43 +01:00
dswbx b57f362e3a fix json field 2025-10-28 16:00:58 +01:00
dswbx e055e477ae chore: bump version to 0.19.0-rc.2 and remove unused server initialization callback in Cloudflare config 2025-10-28 13:14:23 +01:00
dswbx 422f7893b5 docs: updated mcp tools 2025-10-28 11:09:37 +01:00
dswbx 0a50f9850c docs: add timestamps plugin description 2025-10-28 10:44:22 +01:00
dswbx 42f340b189 fix: update syncSecretsOptions condition and enhance documentation for mode configuration
- Changed the condition for syncSecretsOptions to check for both existence and false value.
- Added details about explicit fetch export and integration specifics in the introduction documentation.
- Clarified behavior of UI-only and code-only modes, including configuration application and schema syncing.
2025-10-28 10:42:06 +01:00
dswbx ef41b71921 fix: add modes export, fix event firing with modes and cloudflare 2025-10-28 09:18:16 +01:00
dswbx 2847e64b77 feat: enhance query handling by ignoring undefined values
- Updated query conversion logic to skip undefined values, improving robustness.
- Added tests to validate that undefined values are correctly ignored in query specifications.
2025-10-26 21:22:42 +01:00
dswbx 28390b0b84 refactor: move query.spec.ts 2025-10-26 21:08:11 +01:00
dswbx 0b58cadbd0 feat: implement mergeFilters function and enhance query object merging
- Added mergeFilters function to combine filter objects with priority handling.
- Introduced comprehensive tests for mergeFilters in permissions.spec.ts.
- Created query.spec.ts to validate query structure and expression handling.
- Enhanced error messages in query.ts for better debugging and clarity.
2025-10-26 21:05:11 +01:00
dswbx 574b37abcd fix: update key handling in StorageR2Adapter to conditionally prepend keyPrefix
- Modified getKey method to prepend keyPrefix only if it is not empty, ensuring correct key formatting.
2025-10-26 21:04:37 +01:00
dswbx 0dbf71e6b5 fix pagination on entity relations for softscan false 2025-10-26 16:00:15 +01:00
dswbx fd1f0f7c54 Merge pull request #288 from bknd-io/feat/deno-and-docs
deno: add to cli starters, improve `serveStaticViaImport` and add deno to the docs
2025-10-25 10:37:02 +02:00
dswbx ebad3d15ec Enhance Deno integration documentation with installation instructions and versioning options 2025-10-25 10:34:24 +02:00
dswbx 2fd5e71574 finalize deno addition to the cli starters 2025-10-25 10:26:05 +02:00
dswbx b787837dd2 add deno to the cli starters 2025-10-25 10:18:43 +02:00
dswbx 1fc6e810ae feat: improve Deno support and enhance serveStaticViaImport function
- Introduced support for Deno as a runtime in the documentation.
- Updated serveStaticViaImport function to accept additional options: appendRaw and package.
- Improved error logging in serveStaticViaImport for better debugging.
- Added new Deno integration documentation with examples for serving static assets.
2025-10-24 18:22:58 +02:00
dswbx 88cc406002 chore: update version to 0.19.0-rc.1 and improve error handling in App class
- Bumped version in package.json to 0.19.0-rc.1.
- Changed error throw to console.error in fetch method of App class for better debugging.
- Updated permissions in DataController for the "/types" endpoint to include context for schemaRead.
2025-10-24 18:22:13 +02:00
dswbx 511f639d7a Merge pull request #280 from bknd-io/feat/advanced-permissions
feat: advanced permissions (~RLS)
2025-10-24 15:18:05 +02:00
dswbx 0df17221df Merge branch 'release/0.19' into feat/advanced-permissions 2025-10-24 15:15:56 +02:00
dswbx 347fe0f6ce Merge pull request #286 from bknd-io/feat/data-types-endpoint
feat: add endpoint/tool to retrieve TypeScript definitions for data entities
2025-10-24 15:13:36 +02:00
dswbx f3c6cd7620 fix typo on AdminController flash message 2025-10-24 15:12:47 +02:00
dswbx 649c465bd0 Merge pull request #279 from bknd-io/fix/dropzone-improve-mime
fix: dropzone improve mime type validation
2025-10-24 15:11:19 +02:00
dswbx 67604b344a Merge pull request #284 from bknd-io/fix/sdk-api-auth
fix: SDK improvements to API and `useAuth` for remote instance
2025-10-24 15:10:29 +02:00
dswbx aedae8e757 Merge pull request #285 from bknd-io/feat/mode-helpers
feat: introduce new modes helpers
2025-10-24 15:09:05 +02:00
dswbx 166409fdf4 Revert "make non-fillable fields visible but disabled in UI"
This reverts commit f2aad9caac.
2025-10-24 14:08:32 +02:00
dswbx f2aad9caac make non-fillable fields visible but disabled in UI 2025-10-24 14:07:37 +02:00
dswbx 292e4595ea feat: add endpoint/tool to retrieve TypeScript definitions for data entities
Implemented a new endpoint at "/types" in the DataController to return TypeScript definitions for data entities, enhancing type safety and developer experience.
2025-10-24 12:49:39 +02:00
dswbx 869031bbfa Refactor CustomFieldWrapper and enhance schema handling in Policy component
- Updated `CustomFieldWrapper` to accept a more structured schema object, improving clarity and type safety.
- Modified schema handling in the `Policy` component to ensure proper context and variable naming, enhancing the overall user experience.
- Introduced `autoFormatString` utility for dynamic button labeling based on schema name.
2025-10-24 12:43:32 +02:00
dswbx 88e5c06e9d Enhance SystemController to improve config modification checks
Updated the `SystemController` to include additional checks for read-only status and user permissions when modifying configurations.
2025-10-24 10:37:52 +02:00
dswbx cfb4b0e336 Refactor JsonEditor and Permission components for improved state management and performance
- Implemented debounced input handling in `JsonEditor` to enhance user experience and reduce unnecessary updates.
- Updated `Permission` component to streamline permission state management and improve clarity in policy handling.
- Refactored `Policies` component to utilize derived field context for better data handling and rendering efficiency.
2025-10-24 09:59:00 +02:00
dswbx 2d56b54e0c Enhance Guard and Form components with improved error handling and debugging
- Added debug logging in the `Guard` class to track policy evaluations and conditions.
- Updated error logging in the `Form` component to provide more context on invalid submissions.
- Introduced state management for form errors in the `AuthRolesEdit` component, displaying alerts for invalid data submissions.
2025-10-24 09:40:02 +02:00
dswbx 5d4a77fb10 Update permission context handling and improve JSON field component
- Enhanced `MediaController` to include context in the `entityCreate` permission for better access control.
- Refactored permission checks in `useBkndAuth` to ensure correct validation of role permissions.
- Modified `JsonField` component to directly use `formData` in `JsonEditor`, simplifying data handling and improving user experience.
2025-10-24 09:20:59 +02:00
dswbx eb0822bbff Enhance authentication and authorization components
- Refactored `AppAuth` to introduce `getGuardContextSchema` for improved user context handling.
- Updated `Authenticator` to utilize `pickKeys` for user data extraction in JWT generation.
- Enhanced `Guard` class to improve permission checks and error handling.
- Modified `SystemController` to return context schema alongside permissions in API responses.
- Added new `permissions` method in `SystemApi` for fetching permissions.
- Improved UI components with additional props and tooltip support for better user experience.
2025-10-24 09:14:31 +02:00
dswbx 38902ebcba Update permissions handling and enhance Guard functionality
- Bump `jsonv-ts` dependency to 0.8.6.
- Refactor permission checks in the `Guard` class to improve context validation and error handling.
- Update tests to reflect changes in permission handling, ensuring robust coverage for new scenarios.
- Introduce new test cases for data permissions, enhancing overall test coverage and reliability.
2025-10-21 16:44:08 +02:00
dswbx 22e43c2523 feat: introduce new modes helpers 2025-10-18 16:58:54 +02:00
dswbx e68e5792be set raw state to ClientProviders auth state 2025-10-17 08:47:00 +02:00
dswbx 511c6539fb fix: update authentication verification logic in Api tests
- Adjusted test cases in Api.spec.ts to reflect the correct authentication verification state.
- Updated expectations to ensure that the `isAuthVerified` method returns true when no claims are provided, aligning with the intended behavior of the API.
2025-10-15 18:46:21 +02:00
dswbx 9070f96571 feat: enhance API and AuthApi with credentials support and async storage handling
- Added `credentials` option to `ApiOptions` and `BaseModuleApiOptions` for better request handling.
- Updated `AuthApi` to pass `verified` status during token updates.
- Refactored storage handling in `Api` to support async operations using a Proxy.
- Improved `Authenticator` to handle cookie domain configuration and JSON request detection.
- Adjusted `useAuth` to ensure logout and verify methods return promises for better async handling.
- Fixed navigation URL construction in `useNavigate` and updated context menu actions in `_data.root.tsx`.
2025-10-15 18:41:04 +02:00
dswbx 0347efa592 fix Role creation method and permission checks in tests 2025-10-14 16:49:42 +02:00
dswbx 1b8ce41837 role and permission handling in auth module
- Updated the `Role` class to change the `create` method signature for improved clarity and flexibility.
- Refactored the `guardRoleSchema` to utilize the new `roleSchema` for better consistency.
- Introduced a new `TPermission` type to enhance type safety in permission handling across the application.
- Updated various components and forms to accommodate the new permission structure, ensuring backward compatibility.
- Enhanced the `AuthRolesEdit` and `AuthRolesList` components to improve role management and permissions display.
- Added new API endpoints for fetching permissions, improving the overall functionality of the auth module.
2025-10-14 16:36:42 +02:00
dswbx 6624927286 enhance form field components and add JsonEditor support
- Updated `ObjectField`, `ArrayField`, and `FieldWrapper` components to improve flexibility and integration options by supporting additional props like `wrapperProps`.
- Added `JsonEditor` for enhanced object editing capabilities with state management and safety checks.
- Refactored utility functions and error handling for improved stability and developer experience.
- Introduced new test cases to validate `JsonEditor` functionality and schema-based forms handling.
2025-10-14 16:36:16 +02:00
dswbx 803f42a72b Merge remote-tracking branch 'origin/main' into feat/advanced-permissions 2025-10-14 10:16:43 +02:00
dswbx 7e5c28d621 enhance Guard and permission handling with new test cases
- Updated the `Guard` class to improve context validation and permission checks, ensuring clearer error messages for unmet conditions.
- Refactored the `Policy` and `RolePermission` classes to support default effects and better handle conditions and filters.
- Enhanced tests in `authorize.spec.ts` and `permissions.spec.ts` to cover new permission scenarios, including guest and member role behaviors.
- Added new tests for context validation in permission middleware, ensuring robust error handling for invalid contexts.
- Improved utility functions for better integration with the updated permission structure.
2025-10-13 21:03:49 +02:00
dswbx 2f88c2216c refactor: restructure permission handling and enhance Guard functionality
- Introduced a new `createGuard` function to streamline the creation of Guard instances with permissions and roles.
- Updated tests in `authorize.spec.ts` to reflect changes in permission checks, ensuring they now return undefined for denied permissions.
- Added new `Permission` and `Policy` classes to improve type safety and flexibility in permission management.
- Refactored middleware and controller files to utilize the updated permission structure, including context handling for permissions.
- Created a new `SystemController.spec.ts` file to test the integration of the new permission system within the SystemController.
- Removed legacy permission handling from core security files, consolidating permission logic within the new structure.
2025-10-13 18:20:46 +02:00
dswbx b89c090f87 Merge pull request #281 from stormbyte/main
Update docker image
2025-10-13 11:11:10 +02:00
dswbx f4a7cde487 chore: bump version to 0.18.1 and update jsonv-ts dependency to 0.8.5 2025-10-13 10:58:33 +02:00
dswbx a2d83d01a4 Merge pull request #283 from bknd-io/docs/cloudflare-images-hint
docs: add note about Cloudflare Image Optimization plugin requirement
2025-10-13 10:54:02 +02:00
dswbx 66392d094d Merge pull request #282 from bknd-io/fix/paginate-without-totals
fix pagination if endpoint's total is not available
2025-10-13 10:52:15 +02:00
dswbx 0352c72fb6 docs: add note about Cloudflare Image Optimization plugin requirement
Included a callout in the documentation for the Cloudflare Image Optimization plugin, clarifying that it does not function on the development server or with `workers.dev` subdomains, and requires enabling Cloudflare Image transformations.
2025-10-13 10:51:47 +02:00
dswbx 3f9be3a418 fix: refine FetchPromise execution in useApiInfiniteQuery
Updated the FetchPromise execution in the useApiInfiniteQuery function to include a refine parameter, enhancing the request handling process.
2025-10-13 10:46:04 +02:00
dswbx fd3dd310a5 refactor: enhance MediaApi typing and improve vite example config handling for d1
Updated `MediaApi` to include improved generic typing for upload methods, ensuring type safety and consistency. Refactored example configuration logic in development environment setup for better modularity and maintainability.
2025-10-13 10:41:15 +02:00
dswbx e6ff5c3f0b fix pagination if endpoint's total is not available
when using a connection that has softscans disabled (e.g. D1) pagination failed. Fixing it by overfetching and slicing
2025-10-11 20:37:14 +02:00
Your Name 5377ac1a41 Update docker builder 2025-10-08 22:41:43 +02:00
Your Name 0e870cda81 Bump version 2025-10-08 22:09:03 +02:00
dswbx b784e1c1c4 Merge remote-tracking branch 'origin/release/0.19' into feat/advanced-permissions 2025-10-03 20:27:07 +02:00
dswbx 90f93caff4 refactor: enhance permission handling and introduce new Permission and Policy classes
- Updated the `Guard` class to improve permission checking by utilizing the new `Permission` class.
- Refactored tests in `authorize.spec.ts` to use `Permission` instances instead of strings for better type safety.
- Introduced a new `permissions.spec.ts` file to test the functionality of the `Permission` and `Policy` classes.
- Enhanced the `recursivelyReplacePlaceholders` utility function to support various object structures and types.
- Updated middleware and controller files to align with the new permission handling structure.
2025-10-03 20:22:42 +02:00
dswbx d6dcfe3acc feat: implement file acceptance validation in utils and integrate with Dropzone component 2025-10-01 09:46:16 +02:00
dswbx b974fe7ec7 Merge pull request #270 from bknd-io/feat/cli-user-role
feat: add role selection and auth checks in user create command
2025-10-01 09:40:20 +02:00
dswbx 6483ff74bb Merge pull request #271 from bknd-io/feat/auth-helper-cookie
feat: add helper methods for auth cookie headers
2025-10-01 09:40:03 +02:00
dswbx 27e2064f0c Merge pull request #272 from bknd-io/feat/media-add-body-max
feat: add media option to module to restrict body max size
2025-10-01 09:39:38 +02:00
dswbx 5cf91bb35a Merge pull request #273 from bknd-io/feat/schema-batch-statements
feat: batch schema manager statements
2025-10-01 09:39:19 +02:00
dswbx 81533d855a Merge pull request #277 from bknd-io/feat/timestamps-plugin
feat: timestamps plugin
2025-10-01 09:39:10 +02:00
dswbx db58911df3 Merge pull request #250 from bknd-io/release/0.18
Release 0.18
2025-10-01 09:07:18 +02:00
dswbx 2540c83382 chore: bump version to 0.18.0 in package.json 2025-10-01 09:06:04 +02:00
dswbx 569d021316 fix: update OneToOneRelation and PolymorphicRelation configurations
Enhanced OneToOneRelation to allow source to create target with a mapping field and added a limit. Updated PolymorphicRelation to return a TextField for entity_id instead of NumberField, improving type consistency.
2025-10-01 09:00:44 +02:00
dswbx ba9713587c chore: update Cloudflare proxy build target to ESNext and adjust package import handling
Set the build target for the Cloudflare adapter to ESNext to keep json import attribute.
2025-10-01 09:00:11 +02:00
dswbx eecaeb7c06 chore: bump version to 0.18.0-rc.11 and add cloudflare makeConfig export
Updated package version to 0.18.0-rc.11 and included `makeConfig` in Cloudflare adapter exports to extend configuration capabilities.
2025-09-30 13:32:57 +02:00
dswbx 610e263477 fix: handle correct type conversion for relation fields
Updated `RelationField` to dynamically determine the type based on `target_field_type`. Added a test for proper TypeScript generation with text primary fields in system entities.
2025-09-30 13:32:22 +02:00
dswbx 0f54e8267f fix: exclude internal sqlite indices from introspection
Added a condition to filter out internal SQLite indices named 'sqlite_%' during introspection, ensuring cleaner and more relevant metadata retrieval.
2025-09-30 13:31:49 +02:00
dswbx 1fdee8435d feat: add timestamps plugin to manage created_at and updated_at fields
Introduced a new timestamps plugin that allows the addition of `created_at` and `updated_at` fields to specified entities. Included tests to verify functionality, ensuring timestamps are correctly set on entity creation and updates. Updated the plugin index to export the new timestamps functionality.
2025-09-29 22:12:23 +02:00
dswbx 1eeb23232a chore: bump version to 0.18.0-rc.10 in package.json and update EntityMediaFormField logic for single item handling 2025-09-29 22:10:07 +02:00
dswbx 6102759da8 chore: bump version to 0.18.0-rc.9 in package.json 2025-09-29 17:30:18 +02:00
dswbx 6cea581e42 fix: update database sync warning to clarify behavior in code mode
Revised the warning message for database sync requests to indicate that sync requests are ignored in code mode, as system tables may not be fully represented in the provided configuration.
2025-09-29 17:28:43 +02:00
dswbx 5e553a7fce Merge pull request #276 from bknd-io/feat/media-field-improvements
feat: improve media handling
2025-09-29 17:19:57 +02:00
dswbx 5e71fc8947 feat: implement media cleanup on entity deletion and enhance Dropzone logging
Added functionality to clean up media files associated with entities upon deletion, ensuring proper resource management. Enhanced logging in the Dropzone component to provide better feedback on file type validation and user interactions, improving overall user experience.
2025-09-29 16:47:44 +02:00
dswbx d1ba638cd5 fix: refine mime type checks and improve MediaInfoModal layout
Updated tests to check for mime types using `toStartWith` for better validation. Adjusted mime type patterns in DropzoneInner for XML handling. Modified MediaInfoModal to enhance layout responsiveness and broaden text format support for previews, ensuring better file handling and user experience.
2025-09-29 16:07:40 +02:00
dswbx d31416f85d feat: enhance mime type handling and improve file metadata management
Updated mime types to include additional formats for text and application categories. Improved file metadata handling in the Storage class to accurately reflect file properties during uploads. Enhanced the DropzoneInner component with new icons for various file types and refined the MediaInfoModal to support a broader range of text formats for previews.
2025-09-29 15:55:25 +02:00
dswbx 55082e9d0e feat: improve media handling
added local range requests, fix mime type detection, improve uploading using FormData, correctly use mime type allow list, added previews for audio, pdf and text
2025-09-29 14:55:19 +02:00
dswbx 0d74625270 feat: fix PDF mime type detection and enhance filename handling tests
Updated the mime types to include "pdf" in the application category. Enhanced the test suite for media mime types to validate filename handling for PDF files, ensuring correct extensions are maintained based on file type and name. This improves the robustness of the file handling logic in the application.
2025-09-29 09:55:02 +02:00
dswbx 800f14ede2 fix: fix wrangler platform proxy handling
Modified the external dependencies in the build configuration to include "bknd" and its submodules. Added a new asynchronous function to handle platform proxy options, improving error handling for the "wrangler" import and providing clearer logging for version resolution. Additionally, ensured that the schema manager skips processing for tables without names.
2025-09-29 09:28:37 +02:00
dswbx 560379bd89 docs: added basic Admin UI configuration documentation
Added a new `BkndAdminConfig` type to streamline Admin UI configuration options, consolidating properties for base path, logo return path, theme, entities, and app shell settings. Updated `BkndAdminProps` to utilize this new configuration type. Additionally, introduced a new documentation section for extending the Admin UI, detailing customization options and providing examples for advanced usage.
2025-09-25 10:45:10 +02:00
dswbx daafee2c06 Merge pull request #275 from bknd-io/fix/s3-client-minio
fix: set s3 client service to s3 to produce correct headers required by minio
2025-09-25 09:28:52 +02:00
dswbx ab3e8ce55f fix: set s3 client service to s3 to produce correct headers required by minio 2025-09-25 09:25:13 +02:00
dswbx a655c990ed feat: update documentation to reflect configuration changes and add progress callouts
Added callouts to various documentation modules indicating that the documentation is a work in progress. Updated references from `initialConfig` to `config` in multiple sections to align with recent changes in configuration handling.
2025-09-24 18:14:55 +02:00
dswbx 2c976adb77 chore: bump package version to 0.18.0-rc.8 2025-09-24 16:25:23 +02:00
dswbx 1128ac500d feat: add code-only tests and enhance CLI sync command with seeding option
Introduced a new test suite for code-only applications, validating app creation, database sync behavior, and seeding functionality. Enhanced the CLI sync command to include a seeding option, allowing for explicit seeding during database synchronization. Added error handling for unresolved config files in the run command.
2025-09-24 16:23:16 +02:00
dswbx 06d7558c3c feat: batch schema manager statements
run all schema modification queries in a single batch/transaction, to enable automatic rollbacks, and to stay within cloudflare's subrequest limits in free plan.
2025-09-24 14:48:45 +02:00
dswbx 7344b1cf3d feat: add media option to module to restrict body max size
updated media schema to enforce strict validation, introduced `options` for AppMedia, and added a key prefix feature for StorageR2Adapter to enhance flexibility and control.
2025-09-24 10:29:03 +02:00
dswbx ace9c1b2b9 feat: add helper methods for auth cookie headers
introduced `getAuthCookieHeader` and `removeAuthCookieHeader` methods to simplify header management for authentication cookies. added tests to validate the new methods.
2025-09-24 10:26:07 +02:00
dswbx 0629e1bc50 feat: add role selection and auth checks in user create command
integrated role selection prompt during user creation and added an auth-enabled check to ensure correct configuration before executing commands. adjusted CLI commands to include role assignment for newly created users.
2025-09-24 10:24:37 +02:00
dswbx 832eb6ac31 feat: add migration to version 10 and update tests
introduced a new config migration to version 10, updated related tests to validate migration.
2025-09-24 09:58:22 +02:00
dswbx aa8bf156b0 fix: throw error if fetch called before build
added a check in the fetch getter to prevent its usage before the application is built, ensuring proper usage and avoiding runtime issues.
2025-09-24 09:53:28 +02:00
dswbx 83c1c86eff chore: update Bun version to 1.2.22 and bump package version to 0.18.0-rc.7 2025-09-23 14:00:08 +02:00
dswbx ffe53d3fb5 fix: ensure form updates with latest data after mutation
added a form reset to reflect up-to-date data changes and adjusted mutation to prevent id exclusion, ensuring lists are updated properly.
2025-09-23 13:47:10 +02:00
dswbx 49aee37199 feat: lazy load mcp server 2025-09-23 13:46:39 +02:00
dswbx 54eee8cd34 feat: add schema marking and validation skip mechanism 2025-09-23 13:43:37 +02:00
dswbx 5e62e681e7 feat: introduce DummyConnection class for testing purposes 2025-09-21 14:20:07 +02:00
dswbx 99c1645411 chore: bump version to 0.18.0-rc.6 and fix EntityTypescript for system entities 2025-09-21 14:16:57 +02:00
dswbx 564eab23af docs: improve CLI documentation formatting 2025-09-20 20:02:56 +02:00
dswbx f2da54c92b docs: enhance documentation with new modes and plugins
- Updated documentation to include new modes for configuring bknd (UI-only, Code-only, Hybrid).
- Introduced `syncSecrets` plugin example in the extending plugins documentation.
- Added `react-icons` dependency to package.json and package-lock.json.
- Enhanced various documentation pages with icons and improved content structure.
2025-09-20 19:57:38 +02:00
dswbx cd262097dc chore: bump version to 0.18.0-rc.5 2025-09-20 14:58:11 +02:00
dswbx d1726b23f1 fix: prevent rendering of hidden fields in EntityForm component 2025-09-20 14:51:30 +02:00
dswbx 50e9be833b chore: updated cf deps, improved vite dev 2025-09-20 13:59:41 +02:00
dswbx 806b7427c1 Merge pull request #266 from bknd-io/feat/generic-sqlite-modes
feat: enhance SQLite connection configurations to allow WAL
2025-09-20 10:25:19 +02:00
dswbx 8742938074 docs: swap Bun and Node.js SQLite sections 2025-09-20 10:21:08 +02:00
dswbx 36e61cab3f feat: enhance SQLite connection configurations to allow WAL
Updated the Bun and Node SQLite connection implementations to support additional configuration options, including `onCreateConnection`. Introduced tests for connection creation to validate database instance types and ensure proper callback execution. Improved type exports for better integration with existing code.
2025-09-20 10:16:33 +02:00
dswbx 17d4adbbfa chore: bump version to 0.18.0-rc.4 and enhance test logging
Updated the package version to 0.18.0-rc.4. Improved test logging by disabling console output during tests to reduce noise and enhance readability. Adjusted various test files to implement console log management, ensuring cleaner test outputs.
2025-09-19 20:41:35 +02:00
dswbx d052871fe0 fix: update external dependencies and enhance error logging
Added "wrangler" to the external dependencies in the build configuration. Improved error logging in the Storage class to include the file name when image dimension retrieval fails. Enhanced the InvalidSchemaError to provide additional context by including the schema in the error message.
2025-09-19 20:05:53 +02:00
dswbx b0e5a49b0b Merge pull request #265 from bknd-io/feat/admin-ui-customizations
feat: add admin options for entities and app shell
2025-09-19 20:04:07 +02:00
dswbx 35cbcc221a added missing types export for options, init admin ui docs as patch 2025-09-19 20:01:03 +02:00
dswbx 26d1f2b583 feat: add admin options for entities and app shell
Introduced `BkndAdminEntitiesOptions` and `BkndAdminAppShellOptions` for advanced customization of entity actions, headers, footers, and app shell user menu. Updated related components, hooks, and types for seamless integration with the new configuration options.
2025-09-19 11:36:31 +02:00
dswbx 95d114ea68 test: adjust expectation for beforeBuild call count
Updated the test to expect `beforeBuild` to be called twice, reflecting changes in the test conditions or setup logic.
2025-09-19 11:30:38 +02:00
dswbx 059becbf09 refactor: update id handling for media entity
Revised `id` and `entity_id` types to support both string and number for flexibility. Adjusted `PolymorphicRelation` to use `PrimaryFieldType` for improved type safety and maintainability.
2025-09-19 11:28:51 +02:00
dswbx 91120091a3 feat: add fallback redirect for cloudflare image optimization plugin
Introduced `fallbackRedirect` option to enable a redirect to the original image if image optimization fails. Added logging for platform proxy usage and adjusted `beforeBuild` to handle undefined `app`. Minor improvements and process termination for clear command flow.
2025-09-19 11:28:04 +02:00
dswbx 99a812cc75 test: remove unused parameter from getDummyConnection calls 2025-09-18 10:23:18 +02:00
dswbx 29a2de31c6 bump version to 0.18.0-rc.3 2025-09-18 10:06:56 +02:00
dswbx 2688bf261b Merge pull request #264 from bknd-io/fix/admin-ui-form-improvements
ui: improve form field components, add support for custom fields, export components
2025-09-18 10:04:19 +02:00
dswbx 15c32c0e6d ui: improve form field components, add support for custom fields, export components
Enhanced `FieldLabel` to support tooltips and additional props. Refactored form field components for better customizability and ensured compatibility with new `CustomField`. Updated `onChange` handlers to include context for improved flexibility. Added exports for UI components.
2025-09-18 10:03:16 +02:00
dswbx d6f2500be2 Merge pull request #263 from bknd-io/fix/cf-vite-writer-plugin-large-payloads
fix: implement chunked request handling for cloudflare vite dev-fs plugin
2025-09-18 10:00:19 +02:00
dswbx fa8931ad15 fix: implement chunked request handling for cloudflare vite dev-fs plugin
Reworked the dev-fs plugin to handle chunked write requests efficiently. Added mechanisms to track, process, and validate chunks, ensuring reliable file writes even for large data. Enhanced verbose logging for better debugging.
2025-09-18 09:59:10 +02:00
dswbx 6e3060141b refactor: improve type handling and config structure
Updated various type definitions to improve flexibility and maintain consistency, including `MaybePromise` and `PartialRec`. Adjusted `App` class and related configurations to properly utilize these changes.
2025-09-18 09:58:12 +02:00
dswbx 07029e3797 Merge pull request #262 from bknd-io/fix/typegen-valid-identifiers-and-comments
fix: typegen: adjust field formatting and type name handling
2025-09-18 09:57:01 +02:00
dswbx 3672cc7f2c fix: adjust field formatting and type name handling
Enabled multiline comments for fields to align with entity settings. Updated `typeName` to remove spaces for consistent formatting.
2025-09-18 09:55:33 +02:00
dswbx ac32eb128e Merge pull request #261 from bknd-io/fix/unique-indexes-without-required-first
refactor: remove unique field validation from `EntityIndex`
2025-09-18 09:53:42 +02:00
dswbx ffdf453fea fix: remove non-unique index test
The removed test no longer aligns with the current `EntityIndex` implementation, as non-unique validation is not applicable anymore.
2025-09-18 09:52:05 +02:00
dswbx 9dd7432e6b refactor: remove unique field validation from EntityIndex
Eliminated validation requiring the first field in unique indices to be mandatory. This simplifies the logic as the requirement is no longer necessary.
2025-09-18 09:50:05 +02:00
dswbx 8c4a8d91a2 fix: secret handling and relation naming logic
Implemented improved handling for secrets in `DbModuleManager`, allowing secret merging and building before saving. Enhanced `ManyToManyRelation` naming to produce valid identifiers and ensured better testing coverage for both changes.
2025-09-18 09:47:47 +02:00
dswbx 9aae6e78d6 config: allow full property usage in app function and improve type consistency
Added support for all properties in the `app` function configuration and ensured consistent type definitions for `BkndConfig`. Updated `makeConfig` function to reflect these changes and added relevant unit tests.
2025-09-16 16:41:16 +02:00
dswbx 317b2b50ad fix cli for cloudflare proxy and plugins
- proper cli exists required for cloudflare proxy to dispose
- updated cloudflare proxy to allow proxy options (e.g. remote)
- updated config command to include proper required structure for the export on code mode
2025-09-16 16:08:01 +02:00
dswbx a0b2dde034 fix: improve asset handling, logging, and ui conditions
Refactored asset handling to support multiple CSS entries and fixed context handling in authenticator when executed through mcp. Adjusted UI readonly conditions.
2025-09-16 16:04:09 +02:00
dswbx 70eaa22327 Merge remote-tracking branch 'origin/main' into release/0.18
# Conflicts:
#	app/package.json
2025-09-15 16:29:15 +02:00
dswbx ddfc3e599f docs: corrected app function parameter signature 2025-09-15 14:36:00 +02:00
dswbx c11dd2bd9e Merge pull request #260 from bknd-io/fix/docs-env
update docs: add `bknd.config.ts` example and clarify cloudflare setup
2025-09-15 12:46:40 +02:00
dswbx 77c85bfd5c update docs: add bknd.config.ts example and clarify cloudflare setup
Added a detailed example to `bknd.config.ts` documentation showcasing database connection and custom routes. Updated Cloudflare guide with clearer sections, marking modern assets usage as recommended and workers sites as legacy.
2025-09-15 12:41:49 +02:00
dswbx 732cd31e1f ui: fix appshell section tabs styling 2025-09-05 17:24:47 +02:00
dswbx 1a7670f57a Merge pull request #253 from bknd-io/feat/ui-readonly-and-fixes
ui: reflect readonly mode by hiding controls + various small styling fixes
2025-09-05 17:10:55 +02:00
dswbx af573cc79a ui: reflect readonly mode by hiding controls + various small styling fixes 2025-09-05 17:09:50 +02:00
dswbx 1df87c8a16 bump rc version 2025-09-05 15:47:54 +02:00
dswbx 3c5e3f9638 manager: fix secrets extraction for both modes 2025-09-05 14:14:12 +02:00
dswbx 7a0a7481c0 Merge pull request #252 from bknd-io/feat/manager-secrets-and-plugins
improved module manager's secrets extraction, updated plugins
2025-09-05 13:32:36 +02:00
dswbx bdcc81b2f1 improved module manager's secrets extraction, updated plugins 2025-09-05 13:31:20 +02:00
dswbx 94e168589d Merge pull request #249 from bknd-io/feat/code-first-and-secrets-extract
init code-first mode by splitting module manager
2025-09-04 20:49:45 +02:00
dswbx c22339e4bf Merge pull request #247 from bknd-io/fix/cli-externalize-deps
cli: externalize all direct dependencies to prevent being bundled
2025-09-04 20:43:32 +02:00
dswbx d01058595f update system info result, fix cloudflare export 2025-09-04 20:42:19 +02:00
dswbx fdec5f0693 adapters: remove runtime options, serve app always fresh to prevent race conditions 2025-09-04 19:36:21 +02:00
dswbx e8f2c70279 mm: added secrets extraction for db mode 2025-09-04 15:20:12 +02:00
dswbx 2c5371610b fix types, refactor db manager to db 2025-09-04 13:00:27 +02:00
dswbx 758a89b5d7 fix tests: replace initialConfig with config 2025-09-04 10:44:14 +02:00
dswbx e3888537f9 init code-first mode by splitting module manager 2025-09-04 09:21:35 +02:00
dswbx a559a2eabc cli: externalize all direct dependencies to prevent being bundled 2025-09-03 17:17:07 +02:00
453 changed files with 17157 additions and 4767 deletions
+17 -2
View File
@@ -9,6 +9,21 @@ jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: bknd
ports:
- 5430:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
@@ -20,11 +35,11 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: "1.2.19"
bun-version: "1.3.3"
- name: Install dependencies
working-directory: ./app
run: bun install
run: bun install #--linker=hoisted
- name: Build
working-directory: ./app
+2 -1
View File
@@ -27,7 +27,8 @@ packages/media/.env
.npmrc
/.verdaccio
.idea
.vscode
.vscode/*
!.vscode/settings.json
.git_old
docker/tmp
.debug
+19
View File
@@ -0,0 +1,19 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
//"source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.autoImportFileExcludePatterns": [
"**/dist/**",
"**/node_modules/**/dist/**",
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
],
"typescript.preferences.includePackageJsonAutoImports": "on",
"typescript.tsserver.watchOptions": {
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
}
}
+19 -3
View File
@@ -8,13 +8,17 @@
</a>
</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!
bknd simplifies app development by providing a fully functional visual 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!
It's designed to avoid vendor lock-in and architectural limitations. Built exclusively on [WinterTC Minimum Common Web Platform API](https://min-common-api.proposal.wintertc.org/) for universal compatibility, all functionality (data, auth, media, flows) is modular and opt-in, and infrastructure access is adapter-based with direct access to underlying drivers giving you full control without abstractions getting in your way.
* **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
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS)
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
**For documentation and examples, please visit https://docs.bknd.io.**
@@ -24,6 +28,18 @@ bknd simplifies app development by providing a fully functional backend for data
> Please keep in mind that **bknd** is still under active development
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Use Cases
bknd is a general purpose backend system that implements the primitives almost any backend needs. This way, you can use it for any backend use case, including but not limited to:
- **Content Management System (CMS)** as Wordpress alternative, hosted separately or embedded in your frontend
- **AI Agent Backends** for managing agent state with built-in data persistence, regardless where it is hosted. Optionally communicate over the integrated MCP server.
- **SaaS Products** with multi-tenant data isolation (RLS) and user management, with freedom to choose your own database and storage provider
- **Prototypes & MVPs** to validate ideas quickly without infrastructure overhead
- **API-First Applications** where you need a reliable, type-safe backend without vendor lock-in either with the integrated TypeScript SDK or REST API using OpenAPI
- **IoT & Embedded Devices** where minimal footprint matters
## Size
![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)
@@ -46,13 +62,13 @@ Creating digital products always requires developing both the backend (the logic
* **Media**: Effortlessly manage and serve all your media files.
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
* 🌐 Built on Web Standards for maximum compatibility
* 🛠️ MCP server, client and UI built-in to control your backend
* 🏃‍♂️ Multiple run modes
* standalone using the CLI
* using a JavaScript runtime (Node, Bun, workerd)
* using a React framework (Next.js, React Router, Astro)
* 📦 Official API and React SDK with type-safety
* ⚛️ React elements for auto-configured authentication and media components
* 🛠️ MCP server, client and UI built-in to control your backend
## Structure
The package is mainly split into 4 parts, each serving a specific purpose:
+1
View File
@@ -20,6 +20,7 @@ VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
PLUNK_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
+12
View File
@@ -0,0 +1,12 @@
{
"typescript.preferences.includePackageJsonAutoImports": "off",
"typescript.suggest.autoImports": true,
"typescript.preferences.importModuleSpecifier": "relative",
"search.exclude": {
"**/dist/**": true,
"**/node_modules/**": true
},
"files.exclude": {
"**/dist/**": true
}
}
+7 -4
View File
@@ -1,12 +1,15 @@
import { afterEach, describe, test, expect } from "bun:test";
import { afterEach, describe, test, expect, beforeAll, afterAll } 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";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("App tests", async () => {
test("boots and pongs", async () => {
@@ -19,7 +22,7 @@ describe("App tests", async () => {
test("plugins", async () => {
const called: string[] = [];
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},
@@ -105,7 +108,7 @@ describe("App tests", async () => {
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
});
test.only("drivers", async () => {
test("drivers", async () => {
const called: string[] = [];
const app = new App(dummyConnection, undefined, {
drivers: {
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
hello
+23 -34
View File
@@ -1,4 +1,4 @@
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
@@ -19,50 +19,39 @@ describe("adapter", () => {
expect(
omitKeys(
await adapter.makeConfig(
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
{ env: { TEST: "test" } },
),
["connection"],
),
).toEqual({
initialConfig: { server: { cors: { origin: "test" } } },
config: { server: { cors: { origin: "test" } } },
});
});
/* it.only("...", async () => {
const app = await adapter.createAdapterApp();
}); */
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
it("allows all properties in app function", async () => {
const called = mock(() => null);
const config = await adapter.makeConfig(
{
initialConfig: { server: { cors: { origin: "random" } } },
app: (env) => ({
connection: { url: "test" },
config: { server: { cors: { origin: "test" } } },
options: {
mode: "db",
},
onBuilt: () => {
called();
expect(env).toEqual({ foo: "bar" });
},
}),
},
undefined,
{ id },
{ foo: "bar" },
);
const second = await adapter.createAdapterApp();
const third = await adapter.createAdapterApp(undefined, undefined, { id });
await first.build();
await second.build();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
expect(config.connection).toEqual({ url: "test" });
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
expect(config.options).toEqual({ mode: "db" });
await config.onBuilt?.(null as any);
expect(called).toHaveBeenCalled();
});
adapterTestSuite(bunTestRunner, {
+5 -3
View File
@@ -6,13 +6,16 @@ describe("Api", async () => {
it("should construct without options", () => {
const api = new Api();
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
// verified is true, because no token, user, headers or request given
// therefore nothing to check, auth state is verified
expect(api.isAuthVerified()).toBe(true);
});
it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
expect(api.isAuthVerified()).toBe(true);
});
it("should construct from request (token)", async () => {
@@ -42,7 +45,6 @@ describe("Api", async () => {
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com");
+4 -7
View File
@@ -15,7 +15,7 @@ const mockedBackend = new Hono()
.get("/file/:name", async (c) => {
const { name } = c.req.param();
const file = Bun.file(`${assetsPath}/${name}`);
return new Response(file, {
return new Response(new File([await file.bytes()], name, { type: file.type }), {
headers: {
"Content-Type": file.type,
"Content-Length": file.size.toString(),
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res);
const file = await Bun.file(path);
const file = Bun.file(path);
expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png");
await file.delete();
@@ -154,15 +154,12 @@ describe("MediaApi", () => {
}
// upload via readable from bun
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response
{
const response = (await mockedBackend.request(url)) as Response;
await matches(
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png",
);
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
}
});
});
+18 -4
View File
@@ -1,9 +1,23 @@
import { describe, expect, mock, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { App, createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import * as proto from "data/prototype";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("App", () => {
test("use db mode by default", async () => {
const app = createApp();
await app.build();
expect(app.mode).toBe("db");
expect(app.isReadOnly()).toBe(false);
expect(app.modules instanceof DbModuleManager).toBe(true);
});
test("seed includes ctx and app", async () => {
const called = mock(() => null);
await createApp({
@@ -29,7 +43,7 @@ describe("App", () => {
expect(called).toHaveBeenCalled();
const app = createApp({
initialConfig: {
config: {
data: proto
.em({
todos: proto.entity("todos", {
@@ -139,7 +153,7 @@ describe("App", () => {
test("getMcpClient", async () => {
const app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
+2
View File
@@ -16,6 +16,7 @@ describe("AppServer", () => {
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
@@ -38,6 +39,7 @@ describe("AppServer", () => {
mcp: {
enabled: false,
path: "/api/system/mcp",
logLevel: "warning",
},
});
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test";
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
import { getDummyConnection } from "../../__test__/helper";
import { ModuleManager } from "modules/ModuleManager";
import { em, entity, text } from "data/prototype";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function createApp(config: CreateAppConfig = {}) {
const app = internalCreateApp({
connection: getDummyConnection().dummyConnection,
...config,
options: {
...config.options,
mode: "code",
},
});
await app.build();
return app;
}
describe("code-only", () => {
test("should create app with correct manager", async () => {
const app = await createApp();
await app.build();
expect(app.version()).toBeDefined();
expect(app.modules).toBeInstanceOf(ModuleManager);
});
test("should not perform database syncs", async () => {
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
});
expect(app.em.entities.map((e) => e.name)).toEqual(["test"]);
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute(),
).toEqual([]);
// only perform when explicitly forced
await app.em.schema().sync({ force: true });
expect(
await app.em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute()
.then((r) => r.map((r) => r.name)),
).toEqual(["test", "sqlite_sequence"]);
});
test("should not perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
expect(called).not.toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([]);
});
test("should sync and perform seeding", async () => {
const called = mock(() => null);
const app = await createApp({
config: {
data: em({
test: entity("test", {
name: text(),
}),
}).toJSON(),
},
options: {
seed: async (ctx) => {
called();
await ctx.em.mutator("test").insertOne({ name: "test" });
},
},
});
await app.em.schema().sync({ force: true });
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
expect(called).toHaveBeenCalled();
expect(
await app.em
.repo("test")
.findMany({})
.then((r) => r.data),
).toEqual([{ id: 1, name: "test" }]);
});
test("should not allow to modify config", async () => {
const app = await createApp();
// biome-ignore lint/suspicious/noPrototypeBuiltins: <explanation>
expect(app.modules.hasOwnProperty("mutateConfigSafe")).toBe(false);
expect(() => {
app.modules.configs().auth.enabled = true;
}).toThrow();
});
});
+10 -4
View File
@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
let server: McpServer;
beforeEach(async () => {
app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
jwt: {
@@ -44,6 +44,7 @@ describe("mcp auth", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
@@ -200,7 +201,10 @@ describe("mcp auth", async () => {
},
return_config: true,
});
expect(addGuestRole.config.guest.permissions).toEqual(["read", "write"]);
expect(addGuestRole.config.guest.permissions.map((p) => p.permission)).toEqual([
"read",
"write",
]);
// update role
await tool(server, "config_auth_roles_update", {
@@ -209,13 +213,15 @@ describe("mcp auth", async () => {
permissions: ["read"],
},
});
expect(app.toJSON().auth.roles?.guest?.permissions).toEqual(["read"]);
expect(app.toJSON().auth.roles?.guest?.permissions?.map((p) => p.permission)).toEqual([
"read",
]);
// get role
const getGuestRole = await tool(server, "config_auth_roles_get", {
key: "guest",
});
expect(getGuestRole.value.permissions).toEqual(["read"]);
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
// remove role
await tool(server, "config_auth_roles_remove", {
+11 -2
View File
@@ -1,14 +1,18 @@
import { describe, it, expect } from "bun:test";
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { registries } from "index";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("mcp", () => {
it("should have tools", async () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},
@@ -30,6 +34,11 @@ describe("mcp", () => {
});
await app.build();
// expect mcp to not be loaded yet
expect(app.mcp).toBeNull();
// after first request, mcp should be loaded
await app.getMcpClient().listTools();
expect(app.mcp?.tools.length).toBeGreaterThan(0);
});
});
+2 -1
View File
@@ -41,7 +41,7 @@ describe("mcp data", async () => {
beforeEach(async () => {
const time = performance.now();
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
@@ -50,6 +50,7 @@ describe("mcp data", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
+2 -1
View File
@@ -21,7 +21,7 @@ describe("mcp media", async () => {
beforeEach(async () => {
registries.media.register("local", StorageLocalAdapter);
app = createApp({
initialConfig: {
config: {
media: {
enabled: true,
adapter: {
@@ -39,6 +39,7 @@ describe("mcp media", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
server.setLogLevel("error");
server.onNotification((message) => {
+7 -2
View File
@@ -1,6 +1,10 @@
import { describe, test, expect, beforeAll, mock, beforeEach, afterAll } from "bun:test";
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
/**
* - [x] config_server_get
@@ -11,7 +15,7 @@ describe("mcp system", async () => {
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
@@ -20,6 +24,7 @@ describe("mcp system", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
+7 -2
View File
@@ -1,7 +1,11 @@
import { AppEvents } from "App";
import { describe, test, expect, beforeAll, mock } from "bun:test";
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test";
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
import type { McpServer } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
/**
* - [x] system_config
@@ -14,7 +18,7 @@ describe("mcp system", async () => {
let server: McpServer;
beforeAll(async () => {
app = createApp({
initialConfig: {
config: {
server: {
mcp: {
enabled: true,
@@ -23,6 +27,7 @@ describe("mcp system", async () => {
},
});
await app.build();
await app.getMcpClient().ping();
server = app.mcp!;
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { code, hybrid } from "modes";
describe("modes", () => {
describe("code", () => {
test("verify base configuration", async () => {
const c = code({}) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["options"]);
expect(config.options.mode).toEqual("code");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
test("keeps overrides", async () => {
const c = code({
connection: {
url: ":memory:",
},
}) as any;
const config = await c.app?.({} as any);
expect(config.connection.url).toEqual(":memory:");
});
});
describe("hybrid", () => {
test("fails if no reader is provided", () => {
// @ts-ignore
expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/);
});
test("verify base configuration", async () => {
const c = hybrid({ reader: async () => ({}) }) as any;
const config = await c.app?.({} as any);
expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]);
expect(config.options.mode).toEqual("db");
expect(config.options.plugins).toEqual([]);
expect(config.options.manager.skipValidation).toEqual(false);
expect(config.options.manager.onModulesBuilt).toBeDefined();
});
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect, mock, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { syncConfig } from "plugins/dev/sync-config.plugin";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("syncConfig", () => {
it("should only sync if enabled", async () => {
const called = mock(() => null);
const app = createApp();
await app.build();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: false,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).not.toHaveBeenCalled();
await syncConfig({
write: () => {
called();
},
enabled: true,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(1);
});
it("should respect secrets", async () => {
const called = mock(() => null);
const app = createApp({
config: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await app.build();
await syncConfig({
write: async (config) => {
called();
expect(config.auth.jwt.secret).toBe("test");
},
enabled: true,
includeSecrets: true,
includeFirstBoot: true,
})(app).onBuilt?.();
await syncConfig({
write: async (config) => {
called();
// it's an important test, because the `jwt` part is omitted if secrets=false in general app.toJSON()
// but it's required to get the app running
expect(config.auth.jwt.secret).toBe("");
},
enabled: true,
includeSecrets: false,
includeFirstBoot: true,
})(app).onBuilt?.();
expect(called).toHaveBeenCalledTimes(2);
});
});
+7 -3
View File
@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { registries } from "../../src";
import { createApp } from "core/test/utils";
import * as proto from "../../src/data/prototype";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("repros", async () => {
/**
@@ -72,7 +76,7 @@ describe("repros", async () => {
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
});
test.only("verify inversedBy", async () => {
test("verify inversedBy", async () => {
const schema = proto.em(
{
products: proto.entity("products", {
@@ -88,7 +92,7 @@ describe("repros", async () => {
fns.relation(schema.product_likes).manyToOne(schema.users);
},
);
const app = createApp({ initialConfig: { data: schema.toJSON() } });
const app = createApp({ config: { data: schema.toJSON() } });
await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
+39 -1
View File
@@ -1,3 +1,41 @@
import { Authenticator } from "auth/authenticate/Authenticator";
import { describe, expect, test } from "bun:test";
describe("Authenticator", async () => {});
describe("Authenticator", async () => {
test("should return auth cookie headers", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const headers = await auth.getAuthCookieHeader("token");
const cookie = headers.get("Set-Cookie");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
// now expect it to be removed
const headers2 = await auth.removeAuthCookieHeader(headers);
const cookie2 = headers2.get("Set-Cookie");
expect(cookie2).toStartWith("auth=; Max-Age=0; Path=/; Expires=");
expect(cookie2).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
test("should return auth cookie string", async () => {
const auth = new Authenticator({}, null as any, {
jwt: {
secret: "secret",
fields: [],
},
cookie: {
sameSite: "strict",
},
});
const cookie = await auth.unsafeGetAuthCookie("token");
expect(cookie).toStartWith("auth=");
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
});
});
+182 -20
View File
@@ -1,9 +1,35 @@
import { describe, expect, test } from "bun:test";
import { Guard } from "../../../src/auth/authorize/Guard";
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { Guard, type GuardConfig } from "auth/authorize/Guard";
import { Permission } from "auth/authorize/Permission";
import { Role, type RoleSchema } from "auth/authorize/Role";
import { objectTransform, s } from "bknd/utils";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
function createGuard(
permissionNames: string[],
roles?: Record<string, Omit<RoleSchema, "name">>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.create(name, { permissions, is_default, implicit_allow });
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
describe("authorize", () => {
const read = new Permission("read", {
filterable: true,
});
const write = new Permission("write");
test("basic", async () => {
const guard = Guard.create(
const guard = createGuard(
["read", "write"],
{
admin: {
@@ -16,14 +42,14 @@ describe("authorize", () => {
role: "admin",
};
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
expect(() => guard.granted("something")).toThrow();
expect(() => guard.granted(new Permission("something"), {})).toThrow();
});
test("with default", async () => {
const guard = Guard.create(
const guard = createGuard(
["read", "write"],
{
admin: {
@@ -37,26 +63,26 @@ describe("authorize", () => {
{ enabled: true },
);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(false);
expect(guard.granted(read, {})).toBeUndefined();
expect(() => guard.granted(write, {})).toThrow();
const user = {
role: "admin",
};
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
});
test("guard implicit allow", async () => {
const guard = Guard.create([], {}, { enabled: false });
const guard = createGuard([], {}, { enabled: false });
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write, {})).toBeUndefined();
});
test("role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
const guard = createGuard(["read", "write"], {
admin: {
implicit_allow: true,
},
@@ -66,12 +92,12 @@ describe("authorize", () => {
role: "admin",
};
expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted(read, user)).toBeUndefined();
expect(guard.granted(write, user)).toBeUndefined();
});
test("guard with guest role implicit allow", async () => {
const guard = Guard.create(["read", "write"], {
const guard = createGuard(["read", "write"], {
guest: {
implicit_allow: true,
is_default: true,
@@ -79,7 +105,143 @@ describe("authorize", () => {
});
expect(guard.getUserRole()?.name).toBe("guest");
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(guard.granted(read, {})).toBeUndefined();
expect(guard.granted(write, {})).toBeUndefined();
});
describe("cases", () => {
test("guest none, member deny if user.enabled is false", () => {
const guard = createGuard(
["read"],
{
guest: {
is_default: true,
},
member: {
permissions: [
{
permission: "read",
policies: [
{
condition: {},
effect: "filter",
filter: {
type: "member",
},
},
{
condition: {
"user.enabled": false,
},
effect: "deny",
},
],
},
],
},
},
{ enabled: true },
);
expect(() => guard.granted(read, { role: "guest" })).toThrow();
// member is allowed, because default role permission effect is allow
// and no deny policy is met
expect(guard.granted(read, { role: "member" })).toBeUndefined();
// member is allowed, because deny policy is not met
expect(guard.granted(read, { role: "member", enabled: true })).toBeUndefined();
// member is denied, because deny policy is met
expect(() => guard.granted(read, { role: "member", enabled: false })).toThrow();
// get the filter for member role
expect(guard.filters(read, { role: "member" }).filter).toEqual({
type: "member",
});
// get filter for guest
expect(guard.filters(read, {}).filter).toBeUndefined();
});
test("guest should only read posts that are public", () => {
const read = new Permission(
"read",
{
// make this permission filterable
// without this, `filter` policies have no effect
filterable: true,
},
// expect the context to match this schema
// otherwise exit with 500 to ensure proper policy checking
s.object({
entity: s.string(),
}),
);
const guard = createGuard(
["read"],
{
guest: {
// this permission is applied if no (or invalid) role is provided
is_default: true,
permissions: [
{
permission: "read",
// effect deny means only having this permission, doesn't guarantee access
effect: "deny",
policies: [
{
// only if this condition is met
condition: {
entity: {
$in: ["posts"],
},
},
// the effect is allow
effect: "allow",
},
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
public: true,
},
},
],
},
],
},
// members should be allowed to read all
member: {
permissions: [
{
permission: "read",
},
],
},
},
{ enabled: true },
);
// guest can only read posts
expect(guard.granted(read, {}, { entity: "posts" })).toBeUndefined();
expect(() => guard.granted(read, {}, { entity: "users" })).toThrow();
// and guests can only read public posts
expect(guard.filters(read, {}, { entity: "posts" }).filter).toEqual({
public: true,
});
// member can read posts and users
expect(guard.granted(read, { role: "member" }, { entity: "posts" })).toBeUndefined();
expect(guard.granted(read, { role: "member" }, { entity: "users" })).toBeUndefined();
// member should not have a filter
expect(
guard.filters(read, { role: "member" }, { entity: "posts" }).filter,
).toBeUndefined();
});
});
});
@@ -0,0 +1,327 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import * as proto from "data/prototype";
import { mergeObject } from "core/utils/objects";
import type { App, DB } from "bknd";
import type { CreateUserPayload } from "auth/AppAuth";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
const app = createApp({
config: mergeObject(
{
data: proto
.em(
{
users: proto.systemEntity("users", {}),
posts: proto.entity("posts", {
title: proto.text(),
content: proto.text(),
}),
comments: proto.entity("comments", {
content: proto.text(),
}),
},
({ relation }, { users, posts, comments }) => {
relation(posts).manyToOne(users);
relation(comments).manyToOne(posts);
},
)
.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "secret",
},
},
},
config,
),
});
await app.build();
return app;
}
async function createUsers(app: App, users: CreateUserPayload[]) {
return Promise.all(
users.map(async (user) => {
return await app.createUser(user);
}),
);
}
async function loadFixtures(app: App, fixtures: Record<string, any[]> = {}) {
const results = {} as any;
for (const [entity, data] of Object.entries(fixtures)) {
results[entity] = await app.em
.mutator(entity as any)
.insertMany(data)
.then((result) => result.data);
}
return results;
}
describe("data permissions", async () => {
const app = await makeApp({
server: {
mcp: {
enabled: true,
},
},
auth: {
guard: {
enabled: true,
},
roles: {
guest: {
is_default: true,
permissions: [
{
permission: "system.access.api",
},
{
permission: "data.entity.read",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.create",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.update",
policies: [
{
condition: {
entity: "posts",
},
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
{
permission: "data.entity.delete",
policies: [
{
condition: { entity: "posts" },
},
{
condition: { entity: "posts" },
effect: "filter",
filter: {
users_id: { $isnull: 1 },
},
},
],
},
],
},
},
},
});
const users = [
{ email: "foo@example.com", password: "password" },
{ email: "bar@example.com", password: "password" },
];
const fixtures = {
posts: [
{ content: "post 1", users_id: 1 },
{ content: "post 2", users_id: 2 },
{ content: "post 3", users_id: null },
],
comments: [
{ content: "comment 1", posts_id: 1 },
{ content: "comment 2", posts_id: 2 },
{ content: "comment 3", posts_id: 3 },
],
};
await createUsers(app, users);
const results = await loadFixtures(app, fixtures);
describe("http", async () => {
it("read many", async () => {
// many only includes posts with users_id is null
const res = await app.server.request("/api/data/entity/posts");
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
// same with /query
{
const res = await app.server.request("/api/data/entity/posts/query", {
method: "POST",
});
const data = await res.json().then((r: any) => r.data);
expect(data).toEqual([results.posts[2]]);
}
});
it("read one", async () => {
// one only includes posts with users_id is null
{
const res = await app.server.request("/api/data/entity/posts/1");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(404);
expect(data).toBeUndefined();
}
// read one by allowed id
{
const res = await app.server.request("/api/data/entity/posts/3");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.posts[2]);
}
});
it("read many by reference", async () => {
const res = await app.server.request("/api/data/entity/posts/1/comments");
const data = await res.json().then((r: any) => r.data);
expect(res.status).toBe(200);
expect(data).toEqual(results.comments.filter((c: any) => c.posts_id === 1));
});
it("mutation create one", async () => {
// not allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
}
// allowed
{
const res = await app.server.request("/api/data/entity/posts", {
method: "POST",
body: JSON.stringify({ content: "post 4", users_id: null }),
});
expect(res.status).toBe(201);
}
});
it("mutation update one", async () => {
// update one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "PATCH",
body: JSON.stringify({ content: "post 4" }),
});
expect(res.status).toBe(403);
{
// update one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "PATCH",
body: JSON.stringify({ content: "post 3 (updated)" }),
});
expect(res.status).toBe(200);
expect(await res.json().then((r: any) => r.data.content)).toBe("post 3 (updated)");
}
});
it("mutation update many", async () => {
// update many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
update: { content: "post 4" },
where: { users_id: { $isnull: 0 } },
}),
});
expect(res.status).toBe(200); // because filtered
const _data = await res.json().then((r: any) => r.data.map((p: any) => p.users_id));
expect(_data.every((u: any) => u === null)).toBe(true);
// verify
const data = await app.em
.repo("posts")
.findMany({ select: ["content", "users_id"] })
.then((r) => r.data);
// expect non null users_id to not have content "post 4"
expect(
data.filter((p: any) => p.users_id !== null).every((p: any) => p.content !== "post 4"),
).toBe(true);
// expect null users_id to have content "post 4"
expect(
data.filter((p: any) => p.users_id === null).every((p: any) => p.content === "post 4"),
).toBe(true);
});
const count = async () => {
const {
data: { count: _count },
} = await app.em.repo("posts").count();
return _count;
};
it("mutation delete one", async () => {
const initial = await count();
// delete one: not allowed
const res = await app.server.request("/api/data/entity/posts/1", {
method: "DELETE",
});
expect(res.status).toBe(403);
expect(await count()).toBe(initial);
{
// delete one: allowed
const res = await app.server.request("/api/data/entity/posts/3", {
method: "DELETE",
});
expect(res.status).toBe(200);
expect(await count()).toBe(initial - 1);
}
});
it("mutation delete many", async () => {
// delete many: not allowed
const res = await app.server.request("/api/data/entity/posts", {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
where: {},
}),
});
expect(res.status).toBe(200);
// only deleted posts with users_id is null
const remaining = await app.em
.repo("posts")
.findMany()
.then((r) => r.data);
expect(remaining.every((p: any) => p.users_id !== null)).toBe(true);
});
});
});
@@ -0,0 +1,40 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { em, entity, text } from "data/prototype";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
const schema = em(
{
posts: entity("posts", {
title: text(),
content: text(),
}),
comments: entity("comments", {
content: text(),
}),
},
({ relation }, { posts, comments }) => {
relation(posts).manyToOne(comments);
},
);
describe("DataController (auth)", () => {
test("reading schema.json", async () => {
const { request } = await createAuthTestApp(
{
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
request: new Request("http://localhost/api/data/schema.json"),
},
{
config: { data: schema.toJSON() },
},
);
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
@@ -0,0 +1,41 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { createAuthTestApp } from "./shared";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("SystemController (auth)", () => {
test("reading info", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.info"],
request: new Request("http://localhost/api/system/info"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("reading permissions", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.schema.read"],
request: new Request("http://localhost/api/system/permissions"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
test("access openapi", async () => {
const { request } = await createAuthTestApp({
permission: ["system.access.api", "system.openapi"],
request: new Request("http://localhost/api/system/openapi.json"),
});
expect((await request.guest()).status).toBe(403);
expect((await request.member()).status).toBe(403);
expect((await request.authorized()).status).toBe(200);
expect((await request.admin()).status).toBe(200);
});
});
+171
View File
@@ -0,0 +1,171 @@
import { createApp } from "core/test/utils";
import type { CreateAppConfig } from "App";
import type { RoleSchema } from "auth/authorize/Role";
import { isPlainObject } from "core/utils";
export type AuthTestConfig = {
guest?: RoleSchema;
member?: RoleSchema;
authorized?: RoleSchema;
};
export async function createAuthTestApp(
testConfig: {
permission: AuthTestConfig | string | string[];
request: Request;
},
config: Partial<CreateAppConfig> = {},
) {
let member: RoleSchema | undefined;
let authorized: RoleSchema | undefined;
let guest: RoleSchema | undefined;
if (isPlainObject(testConfig.permission)) {
if (testConfig.permission.guest)
guest = {
...testConfig.permission.guest,
is_default: true,
};
if (testConfig.permission.member) member = testConfig.permission.member;
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
} else {
member = {
permissions: [],
};
authorized = {
permissions: Array.isArray(testConfig.permission)
? testConfig.permission
: [testConfig.permission],
};
guest = {
permissions: [],
is_default: true,
};
}
console.log("authorized", authorized);
const app = createApp({
...config,
config: {
...config.config,
auth: {
...config.config?.auth,
enabled: true,
guard: {
enabled: true,
...config.config?.auth?.guard,
},
jwt: {
...config.config?.auth?.jwt,
secret: "secret",
},
roles: {
...config.config?.auth?.roles,
guest,
member,
authorized,
admin: {
implicit_allow: true,
},
},
},
},
});
await app.build();
const users = {
guest: null,
member: await app.createUser({
email: "member@test.com",
password: "12345678",
role: "member",
}),
authorized: await app.createUser({
email: "authorized@test.com",
password: "12345678",
role: "authorized",
}),
admin: await app.createUser({
email: "admin@test.com",
password: "12345678",
role: "admin",
}),
} as const;
const tokens = {} as Record<keyof typeof users, string>;
for (const [key, user] of Object.entries(users)) {
if (user) {
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
}
}
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
const headers = new Headers(init.headers ?? {});
if (user in tokens) {
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
}
const res = await app.server.request(input, {
...init,
headers,
});
let data: any;
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
data = await res.json();
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
data = await res.text();
}
return {
status: res.status,
ok: res.ok,
headers: Object.fromEntries(res.headers.entries()),
data,
};
}
const requestFn = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async (input: string, init: RequestInit = {}) => {
return makeRequest(prop, input, init);
};
},
},
) as {
[K in keyof typeof users]: (
input: string,
init?: RequestInit,
) => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
const request = new Proxy(
{},
{
get(_, prop: keyof typeof users) {
return async () => {
return makeRequest(prop, testConfig.request.url, {
headers: testConfig.request.headers,
method: testConfig.request.method,
body: testConfig.request.body,
});
};
},
},
) as {
[K in keyof typeof users]: () => Promise<{
status: number;
ok: boolean;
headers: Record<string, string>;
data: any;
}>;
};
return { app, users, request, requestFn };
}
@@ -0,0 +1,543 @@
import { describe, it, expect } from "bun:test";
import { s } from "bknd/utils";
import { Permission } from "auth/authorize/Permission";
import { Policy } from "auth/authorize/Policy";
import { Hono } from "hono";
import { getPermissionRoutes, permission } from "auth/middlewares/permission.middleware";
import { auth } from "auth/middlewares/auth.middleware";
import { Guard, mergeFilters, type GuardConfig } from "auth/authorize/Guard";
import { Role, RolePermission } from "auth/authorize/Role";
import { Exception } from "bknd";
import { convert } from "core/object/query/object-query";
describe("Permission", () => {
it("works with minimal schema", () => {
expect(() => new Permission("test")).not.toThrow();
});
it("parses context", () => {
const p = new Permission(
"test3",
{
filterable: true,
},
s.object({
a: s.string(),
}),
);
// @ts-expect-error
expect(() => p.parseContext({ a: [] })).toThrow();
expect(p.parseContext({ a: "test" })).toEqual({ a: "test" });
// @ts-expect-error
expect(p.parseContext({ a: 1 })).toEqual({ a: "1" });
});
});
describe("Policy", () => {
it("works with minimal schema", () => {
expect(() => new Policy().toJSON()).not.toThrow();
});
it("checks condition", () => {
const p = new Policy({
condition: {
a: 1,
},
});
expect(p.meetsCondition({ a: 1 })).toBe(true);
expect(p.meetsCondition({ a: 2 })).toBe(false);
expect(p.meetsCondition({ a: 1, b: 1 })).toBe(true);
expect(p.meetsCondition({})).toBe(false);
const p2 = new Policy({
condition: {
a: { $gt: 1 },
$or: {
b: { $lt: 2 },
},
},
});
expect(p2.meetsCondition({ a: 2 })).toBe(true);
expect(p2.meetsCondition({ a: 1 })).toBe(false);
expect(p2.meetsCondition({ a: 1, b: 1 })).toBe(true);
});
it("filters", () => {
const p = new Policy({
filter: {
age: { $gt: 18 },
},
});
const subjects = [{ age: 19 }, { age: 17 }, { age: 12 }];
expect(p.getFiltered(subjects)).toEqual([{ age: 19 }]);
expect(p.meetsFilter({ age: 19 })).toBe(true);
expect(p.meetsFilter({ age: 17 })).toBe(false);
expect(p.meetsFilter({ age: 12 })).toBe(false);
});
it("replaces placeholders", () => {
const p = new Policy({
condition: {
a: "@auth.username",
},
filter: {
a: "@auth.username",
},
});
const vars = { auth: { username: "test" } };
expect(p.meetsCondition({ a: "test" }, vars)).toBe(true);
expect(p.meetsCondition({ a: "test2" }, vars)).toBe(false);
expect(p.meetsCondition({ a: "test2" })).toBe(false);
expect(p.meetsFilter({ a: "test" }, vars)).toBe(true);
expect(p.meetsFilter({ a: "test2" }, vars)).toBe(false);
expect(p.meetsFilter({ a: "test2" })).toBe(false);
});
});
describe("Guard", () => {
it("collects filters", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
]);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(guard.filters(p, { role: r.name }, { a: 1 }).filter).toEqual({ foo: "bar" });
expect(guard.filters(p, { role: r.name }, { a: 2 }).filter).toBeUndefined();
// if no user context given, filter cannot be applied
expect(guard.filters(p, {}, { a: 1 }).filter).toBeUndefined();
});
it("collects filters for default role", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number(),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: { a: { $eq: 1 } },
filter: { foo: "bar" },
effect: "filter",
}),
]),
],
true,
);
const guard = new Guard([p], [r], {
enabled: true,
});
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 1 },
).filter,
).toEqual({ foo: "bar" });
expect(
guard.filters(
p,
{
role: r.name,
},
{ a: 2 },
).filter,
).toBeUndefined();
// if no user context given, the default role is applied
// hence it can be found
expect(guard.filters(p, {}, { a: 1 }).filter).toEqual({ foo: "bar" });
});
it("merges filters correctly", () => {
expect(mergeFilters({ foo: "bar" }, { baz: "qux" })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { baz: { $eq: "qux" } })).toEqual({
foo: { $eq: "bar" },
baz: { $eq: "qux" },
});
expect(mergeFilters({ foo: "bar" }, { foo: "baz" })).toEqual({ foo: { $eq: "baz" } });
expect(mergeFilters({ foo: "bar" }, { foo: { $lt: 1 } })).toEqual({
foo: { $eq: "bar", $lt: 1 },
});
// overwrite base $or with priority
expect(mergeFilters({ $or: { foo: "one" } }, { foo: "bar" })).toEqual({
$or: {
foo: {
$eq: "bar",
},
},
foo: {
$eq: "bar",
},
});
// ignore base $or if priority has different key
expect(mergeFilters({ $or: { other: "one" } }, { foo: "bar" })).toEqual({
$or: {
other: {
$eq: "one",
},
},
foo: {
$eq: "bar",
},
});
});
});
describe("permission middleware", () => {
const makeApp = (
permissions: Permission<any, any, any, any>[],
roles: Role[] = [],
config: Partial<GuardConfig> = {},
) => {
const app = {
module: {
auth: {
enabled: true,
},
},
modules: {
ctx: () => ({
guard: new Guard(permissions, roles, {
enabled: true,
...config,
}),
}),
},
};
return new Hono()
.use(async (c, next) => {
// @ts-expect-error
c.set("app", app);
await next();
})
.use(auth())
.onError((err, c) => {
if (err instanceof Exception) {
return c.json(err.toJSON(), err.code as any);
}
return c.json({ error: err.message }, "code" in err ? (err.code as any) : 500);
});
};
it("allows if guard is disabled", async () => {
const p = new Permission("test");
const hono = makeApp([p], [], { enabled: false }).get("/test", permission(p, {}), async (c) =>
c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
expect(await res.text()).toBe("test");
});
it("denies if guard is enabled", async () => {
const p = new Permission("test");
const hono = makeApp([p]).get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user has (plain) role", async () => {
const p = new Permission("test");
const r = Role.create("test", { permissions: [p.name] });
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("allows if user has role with policy", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $gte: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("denies if user with role doesn't meet condition", async () => {
const p = new Permission("test");
const r = new Role("test", [
new RolePermission(
p,
[
new Policy({
condition: {
a: { $lt: 1 },
},
// default effect is allow
}),
],
// change default effect to deny if no condition is met
"deny",
),
]);
const hono = makeApp([p], [r], {
context: {
a: 1,
},
})
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get("/test", permission(p, {}), async (c) => c.text("test"));
const res = await hono.request("/test");
expect(res.status).toBe(403);
});
it("allows if user with role doesn't meet condition (from middleware)", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
expect(res.status).toBe(200);
});
it("throws if permission context is invalid", async () => {
const p = new Permission(
"test",
{},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
const hono = makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user: { id: 0, role: r.name } });
await next();
})
.get(
"/test",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
);
const res = await hono.request("/test");
// expecting 500 because bknd should have handled it correctly
expect(res.status).toBe(500);
});
it("checks context on routes with permissions", async () => {
const make = (user: any) => {
const p = new Permission(
"test",
{},
s.object({
a: s.number(),
}),
);
const r = new Role("test", [
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
}),
]),
]);
return makeApp([p], [r])
.use(async (c, next) => {
// @ts-expect-error
c.set("auth", { registered: true, user });
await next();
})
.get(
"/valid",
permission(p, {
context: (c) => ({
a: 1,
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid",
permission(p, {
// @ts-expect-error
context: (c) => ({
b: "1",
}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid2",
permission(p, {
// @ts-expect-error
context: (c) => ({}),
}),
async (c) => c.text("test"),
)
.get(
"/invalid3",
// @ts-expect-error
permission(p),
async (c) => c.text("test"),
);
};
const hono = make({ id: 0, role: "test" });
const valid = await hono.request("/valid");
expect(valid.status).toBe(200);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
{
const hono = make(null);
const valid = await hono.request("/valid");
expect(valid.status).toBe(403);
const invalid = await hono.request("/invalid");
expect(invalid.status).toBe(500);
const invalid2 = await hono.request("/invalid2");
expect(invalid2.status).toBe(500);
const invalid3 = await hono.request("/invalid3");
expect(invalid3.status).toBe(500);
}
});
});
describe("Role", () => {
it("serializes and deserializes", () => {
const p = new Permission(
"test",
{
filterable: true,
},
s.object({
a: s.number({ minimum: 2 }),
}),
);
const r = new Role(
"test",
[
new RolePermission(p, [
new Policy({
condition: {
a: { $eq: 1 },
},
effect: "deny",
filter: {
b: { $lt: 1 },
},
}),
]),
],
true,
);
const json = JSON.parse(JSON.stringify(r.toJSON()));
const r2 = Role.create(p.name, json);
expect(r2.toJSON()).toEqual(r.toJSON());
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -66,4 +66,14 @@ describe("object-query", () => {
expect(result).toBe(expected);
}
});
test("paths", () => {
const result = validate({ "user.age": { $lt: 18 } }, { user: { age: 17 } });
expect(result).toBe(true);
});
test("empty filters", () => {
const result = validate({}, { user: { age: 17 } });
expect(result).toBe(true);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, test } from "bun:test";
import {
makeValidator,
exp,
Expression,
isPrimitive,
type Primitive,
} from "../../../src/core/object/query/query";
describe("query", () => {
test("isPrimitive", () => {
expect(isPrimitive(1)).toBe(true);
expect(isPrimitive("1")).toBe(true);
expect(isPrimitive(true)).toBe(true);
expect(isPrimitive(false)).toBe(true);
// not primitives
expect(isPrimitive(null)).toBe(false);
expect(isPrimitive(undefined)).toBe(false);
expect(isPrimitive([])).toBe(false);
expect(isPrimitive({})).toBe(false);
expect(isPrimitive(Symbol("test"))).toBe(false);
expect(isPrimitive(new Date())).toBe(false);
expect(isPrimitive(new Error())).toBe(false);
expect(isPrimitive(new Set())).toBe(false);
expect(isPrimitive(new Map())).toBe(false);
});
test("strict expression creation", () => {
// @ts-expect-error
expect(() => exp()).toThrow();
// @ts-expect-error
expect(() => exp("")).toThrow();
// @ts-expect-error
expect(() => exp("invalid")).toThrow();
// @ts-expect-error
expect(() => exp("$eq")).toThrow();
// @ts-expect-error
expect(() => exp("$eq", 1)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null)).toThrow();
// @ts-expect-error
expect(() => exp("$eq", () => null, 1)).toThrow();
expect(
exp(
"$eq",
() => true,
() => null,
),
).toBeInstanceOf(Expression);
});
test("$eq is required", () => {
expect(() => makeValidator([])).toThrow();
expect(() =>
makeValidator([
exp(
"$valid",
() => true,
() => null,
),
]),
).toThrow();
expect(
makeValidator([
exp(
"$eq",
() => true,
() => null,
),
]),
).toBeDefined();
});
test("validates filter structure", () => {
const validator = makeValidator([
exp(
"$eq",
(v: Primitive) => isPrimitive(v),
(e, a) => e === a,
),
exp(
"$like",
(v: string) => typeof v === "string",
(e, a) => e === a,
),
]);
// @ts-expect-error intentionally typed as union of given expression keys
expect(validator.expressionKeys).toEqual(["$eq", "$like"]);
// @ts-expect-error "$and" is not allowed
expect(() => validator.convert({ $and: {} })).toThrow();
// @ts-expect-error "$or" must be an object
expect(() => validator.convert({ $or: [] })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { invalid: "bar" } })).toThrow();
// @ts-expect-error "invalid" is not a valid expression key
expect(() => validator.convert({ foo: { $invalid: "bar" } })).toThrow();
// @ts-expect-error "null" is not a valid value
expect(() => validator.convert({ foo: null })).toThrow();
// @ts-expect-error only primitives are allowed for $eq
expect(() => validator.convert({ foo: { $eq: [] } })).toThrow();
// @ts-expect-error only strings are allowed for $like
expect(() => validator.convert({ foo: { $like: 1 } })).toThrow();
// undefined values are ignored
expect(validator.convert({ foo: undefined })).toEqual({});
expect(validator.convert({ foo: "bar" })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $eq: "bar" } })).toEqual({ foo: { $eq: "bar" } });
expect(validator.convert({ foo: { $like: "bar" } })).toEqual({ foo: { $like: "bar" } });
});
});
+209 -4
View File
@@ -194,6 +194,182 @@ describe("Core Utils", async () => {
expect(result).toEqual(expected);
}
});
test("recursivelyReplacePlaceholders", () => {
// test basic replacement with simple pattern
const obj1 = { a: "Hello, {$name}!", b: { c: "Hello, {$name}!" } };
const variables1 = { name: "John" };
const result1 = utils.recursivelyReplacePlaceholders(obj1, /\{\$(\w+)\}/g, variables1);
expect(result1).toEqual({ a: "Hello, John!", b: { c: "Hello, John!" } });
// test the specific example from the user request
const obj2 = { some: "value", here: "@auth.user" };
const variables2 = { auth: { user: "what" } };
const result2 = utils.recursivelyReplacePlaceholders(obj2, /^@([a-z\.]+)$/, variables2);
expect(result2).toEqual({ some: "value", here: "what" });
// test with arrays
const obj3 = { items: ["@config.name", "static", "@config.version"] };
const variables3 = { config: { name: "MyApp", version: "1.0.0" } };
const result3 = utils.recursivelyReplacePlaceholders(obj3, /^@([a-z\.]+)$/, variables3);
expect(result3).toEqual({ items: ["MyApp", "static", "1.0.0"] });
// test with nested objects and deep paths
const obj4 = {
user: "@auth.user.name",
settings: {
theme: "@ui.theme",
nested: {
value: "@deep.nested.value",
},
},
};
const variables4 = {
auth: { user: { name: "Alice" } },
ui: { theme: "dark" },
deep: { nested: { value: "found" } },
};
const result4 = utils.recursivelyReplacePlaceholders(obj4, /^@([a-z\.]+)$/, variables4);
expect(result4).toEqual({
user: "Alice",
settings: {
theme: "dark",
nested: {
value: "found",
},
},
});
// test with missing paths (should return original match)
const obj5 = { value: "@missing.path" };
const variables5 = { existing: "value" };
const result5 = utils.recursivelyReplacePlaceholders(obj5, /^@([a-z\.]+)$/, variables5);
expect(result5).toEqual({ value: "@missing.path" });
// test with non-matching strings (should remain unchanged)
const obj6 = { value: "normal string", other: "not@matching" };
const variables6 = { some: "value" };
const result6 = utils.recursivelyReplacePlaceholders(obj6, /^@([a-z\.]+)$/, variables6);
expect(result6).toEqual({ value: "normal string", other: "not@matching" });
// test with primitive values (should handle gracefully)
expect(
utils.recursivelyReplacePlaceholders("@test.value", /^@([a-z\.]+)$/, {
test: { value: "replaced" },
}),
).toBe("replaced");
expect(utils.recursivelyReplacePlaceholders(123, /^@([a-z\.]+)$/, {})).toBe(123);
expect(utils.recursivelyReplacePlaceholders(null, /^@([a-z\.]+)$/, {})).toBe(null);
// test type preservation for full string matches
const variables7 = { test: { value: 123, flag: true, data: null, arr: [1, 2, 3] } };
const result7 = utils.recursivelyReplacePlaceholders(
{
number: "@test.value",
boolean: "@test.flag",
nullValue: "@test.data",
array: "@test.arr",
},
/^@([a-z\.]+)$/,
variables7,
null,
);
expect(result7).toEqual({
number: 123,
boolean: true,
nullValue: null,
array: [1, 2, 3],
});
// test partial string replacement (should convert to string)
const result8 = utils.recursivelyReplacePlaceholders(
{ message: "The value is @test.value!" },
/@([a-z\.]+)/g,
variables7,
);
expect(result8).toEqual({ message: "The value is 123!" });
// test with fallback parameter
const obj9 = { user: "@user.id", config: "@config.theme" };
const variables9 = {}; // empty context
const result9 = utils.recursivelyReplacePlaceholders(
obj9,
/^@([a-z\.]+)$/,
variables9,
null,
);
expect(result9).toEqual({ user: null, config: null });
// test with fallback for partial matches
const obj10 = { message: "Hello @user.name, welcome!" };
const variables10 = {}; // empty context
const result10 = utils.recursivelyReplacePlaceholders(
obj10,
/@([a-z\.]+)/g,
variables10,
"Guest",
);
expect(result10).toEqual({ message: "Hello Guest, welcome!" });
// test with different fallback types
const obj11 = {
stringFallback: "@missing.string",
numberFallback: "@missing.number",
booleanFallback: "@missing.boolean",
objectFallback: "@missing.object",
};
const variables11 = {};
const result11 = utils.recursivelyReplacePlaceholders(
obj11,
/^@([a-z\.]+)$/,
variables11,
"default",
);
expect(result11).toEqual({
stringFallback: "default",
numberFallback: "default",
booleanFallback: "default",
objectFallback: "default",
});
// test fallback with arrays
const obj12 = { items: ["@item1", "@item2", "static"] };
const variables12 = { item1: "found" }; // item2 is missing
const result12 = utils.recursivelyReplacePlaceholders(
obj12,
/^@([a-zA-Z0-9\.]+)$/,
variables12,
"missing",
);
expect(result12).toEqual({ items: ["found", "missing", "static"] });
// test fallback with nested objects
const obj13 = {
user: "@user.id",
settings: {
theme: "@theme.name",
nested: {
value: "@deep.value",
},
},
};
const variables13 = {}; // empty context
const result13 = utils.recursivelyReplacePlaceholders(
obj13,
/^@([a-z\.]+)$/,
variables13,
null,
);
expect(result13).toEqual({
user: null,
settings: {
theme: null,
nested: {
value: null,
},
},
});
});
});
describe("file", async () => {
@@ -248,7 +424,7 @@ describe("Core Utils", async () => {
expect(utils.getContentName(request)).toBe(name);
});
test.only("detectImageDimensions", async () => {
test("detectImageDimensions", async () => {
// wrong
// @ts-expect-error
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
@@ -264,15 +440,44 @@ describe("Core Utils", async () => {
height: 512,
});
});
test("isFileAccepted", () => {
const file = new File([""], "file.txt", {
type: "text/plain",
});
expect(utils.isFileAccepted(file, "text/plain")).toBe(true);
expect(utils.isFileAccepted(file, "text/plain,text/html")).toBe(true);
expect(utils.isFileAccepted(file, "text/html")).toBe(false);
{
const file = new File([""], "file.jpg", {
type: "image/jpeg",
});
expect(utils.isFileAccepted(file, "image/jpeg")).toBe(true);
expect(utils.isFileAccepted(file, "image/jpeg,image/png")).toBe(true);
expect(utils.isFileAccepted(file, "image/png")).toBe(false);
expect(utils.isFileAccepted(file, "image/*")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg")).toBe(true);
expect(utils.isFileAccepted(file, ".jpg,.png")).toBe(true);
expect(utils.isFileAccepted(file, ".png")).toBe(false);
}
{
const file = new File([""], "file.png");
expect(utils.isFileAccepted(file, undefined as any)).toBe(true);
}
expect(() => utils.isFileAccepted(null as any, "text/plain")).toThrow();
});
});
describe("dates", () => {
test.only("formats local time", () => {
test("formats local time", () => {
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
console.log(utils.datetimeStringUTC(new Date()));
/*console.log(utils.datetimeStringUTC(new Date()));
console.log(utils.datetimeStringUTC());
console.log(new Date());
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
});
});
});
+3 -2
View File
@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
import { Entity, EntityManager, type EntityData } from "data/entities";
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
import { ManyToOneRelation } from "data/relations";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
beforeAll(() => disableConsoleLog(["log", "warn"]));
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
const dataConfig = parse(dataConfigSchema, {});
+2 -2
View File
@@ -30,9 +30,9 @@ describe("some tests", async () => {
const query = await em.repository(users).findId(1);
expect(query.sql).toBe(
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? order by "users"."id" asc limit ? offset ?',
);
expect(query.parameters).toEqual([1, 1]);
expect(query.parameters).toEqual([1, 1, 0]);
expect(query.data).toBeUndefined();
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, beforeAll, afterAll, test } from "bun:test";
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
import { pg, postgresJs } from "bknd";
import { Pool } from "pg";
import postgres from "postgres";
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
import { $ } from "bun";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const credentials = {
host: "localhost",
port: 5430,
user: "postgres",
password: "postgres",
database: "bknd",
};
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
const kysely = connection.kysely;
// drop all tables+indexes & create new schema
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
await kysely.schema.createSchema("public").execute();
}
async function isPostgresRunning() {
try {
// Try to actually connect to PostgreSQL
const conn = pg({ pool: new Pool(credentials) });
await conn.ping();
await conn.close();
return true;
} catch (e) {
return false;
}
}
describe("postgres", () => {
beforeAll(async () => {
if (!(await isPostgresRunning())) {
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
await $waitUntil("Postgres is running", isPostgresRunning);
await new Promise((resolve) => setTimeout(resolve, 500));
}
disableConsoleLog();
});
afterAll(async () => {
if (await isPostgresRunning()) {
try {
await $`docker stop bknd-test-postgres`;
} catch (e) {}
}
enableConsoleLog();
});
describe.serial.each([
["pg", () => pg({ pool: new Pool(credentials) })],
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
])("%s", (name, createConnection) => {
connectionTestSuite(
{
...bunTestRunner,
test: test.serial,
},
{
makeConnection: () => {
const connection = createConnection();
return {
connection,
dispose: async () => {
await cleanDatabase(connection);
await connection.close();
},
};
},
rawDialectDetails: [],
disableConsoleLog: false,
},
);
});
});
+5 -2
View File
@@ -1,12 +1,15 @@
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations";
import { TextField } from "data/fields";
import { JoinBuilder } from "data/entities/query/JoinBuilder";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] JoinBuilder", async () => {
test("missing relation", async () => {
+3 -2
View File
@@ -9,13 +9,14 @@ import {
} from "data/relations";
import { NumberField, TextField } from "data/fields";
import * as proto from "data/prototype";
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
import { getDummyConnection } from "../../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { MutatorEvents } from "data/events";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog(["log", "warn"]));
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
describe("[data] Mutator (base)", async () => {
+79 -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 { Kysely, Transaction } from "kysely";
import { TextField } from "data/fields";
import { em as $em, entity as $entity, text as $text } from "data/prototype";
@@ -6,11 +6,13 @@ import { Entity, EntityManager } from "data/entities";
import { ManyToOneRelation } from "data/relations";
import { RepositoryEvents } from "data/events";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
type E = Kysely<any> | Transaction<any>;
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterAll(afterAllCleanup);
beforeAll(() => disableConsoleLog());
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
async function sleep(ms: number) {
return new Promise((resolve) => {
@@ -122,6 +124,81 @@ describe("[Repository]", async () => {
.then((r) => [r.count, r.total]),
).resolves.toEqual([undefined, undefined]);
});
test("auto join", async () => {
const schema = $em(
{
posts: $entity("posts", {
title: $text(),
content: $text(),
}),
comments: $entity("comments", {
content: $text(),
}),
another: $entity("another", {
title: $text(),
}),
},
({ relation }, { posts, comments }) => {
relation(comments).manyToOne(posts);
},
);
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
await em
.mutator("comments")
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
const res = await em.repo("comments").findMany({
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
{
// manual join should still work
const res = await em.repo("comments").findMany({
join: ["posts"],
where: {
"posts.title": "post1",
},
});
expect(res.data as any).toEqual([
{
id: 1,
content: "comment1",
posts_id: 1,
},
]);
}
// inexistent should be detected and thrown
expect(
em.repo("comments").findMany({
where: {
"random.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
// existing alias, but not a relation should throw
expect(
em.repo("comments").findMany({
where: {
"another.title": "post1",
},
}),
).rejects.toThrow(/Invalid where field/);
});
});
describe("[data] Repository (Events)", async () => {
+36 -1
View File
@@ -1,5 +1,5 @@
// eslint-disable-next-line import/no-unresolved
import { afterAll, describe, expect, test } from "bun:test";
import { afterAll, describe, expect, spyOn, test } from "bun:test";
import { randomString } from "core/utils";
import { Entity, EntityManager } from "data/entities";
import { TextField, EntityIndex } from "data/fields";
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
const diffAfter = await em.schema().getDiff();
expect(diffAfter.length).toBe(0);
});
test("returns statements", async () => {
const amount = 5;
const entities = new Array(amount)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const statements = await em.schema().sync({ force: true });
expect(statements.length).toBe(amount);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
});
test("batches statements", async () => {
const { dummyConnection } = getDummyConnection();
const entities = new Array(20)
.fill(0)
.map(() => new Entity(randomString(16), [new TextField("text")]));
const em = new EntityManager(entities, dummyConnection);
const spy = spyOn(em.connection, "executeQueries");
const statements = await em.schema().sync();
expect(statements.length).toBe(entities.length);
expect(statements.every((stmt) => Object.keys(stmt).join(",") === "sql,parameters")).toBe(
true,
);
await em.schema().sync({ force: true });
expect(spy).toHaveBeenCalledTimes(1);
const tables = await em.connection.kysely
.selectFrom("sqlite_master")
.where("type", "=", "table")
.selectAll()
.execute();
expect(tables.length).toBe(entities.length + 1); /* 1+ for sqlite_sequence */
});
});
+5 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { Entity, EntityManager } from "data/entities";
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
import { TextField } from "data/fields";
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
import { WithBuilder } from "data/entities/query/WithBuilder";
import { schemaToEm } from "../../helper";
import { getDummyConnection } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
const { dummyConnection } = getDummyConnection();
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER",
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
hasDefaultValue: true,
comment: undefined,
},
{
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
dataType: "INTEGER",
isNullable: false,
isAutoIncrementing: true,
hasDefaultValue: false,
hasDefaultValue: true,
comment: undefined,
},
{
@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
expect(index.name).toEqual("idx_test_name");
expect(index.unique).toEqual(false);
});
test("it fails on non-unique", async () => {
const field = new TestField("name", { required: false });
expect(() => new EntityIndex(entity, [field], true)).toThrowError();
expect(() => new EntityIndex(entity, [field])).toBeDefined();
});
});
@@ -7,7 +7,7 @@ describe("[data] JsonField", async () => {
const field = new JsonField("test");
fieldTestSuite(bunTestRunner, JsonField, {
defaultValue: { a: 1 },
sampleValues: ["string", { test: 1 }, 1],
//sampleValues: ["string", { test: 1 }, 1],
schemaType: "text",
});
@@ -33,9 +33,9 @@ describe("[data] JsonField", async () => {
});
test("getValue", async () => {
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
expect(field.getValue("string", "form")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
expect(field.getValue("string", "form")).toBe("string");
expect(field.getValue(1, "form")).toBe(1);
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
expect(field.getValue('"string"', "submit")).toBe("string");
@@ -43,6 +43,5 @@ describe("[data] JsonField", async () => {
expect(field.getValue({ test: 1 }, "table")).toBe('{"test":1}');
expect(field.getValue("string", "table")).toBe('"string"');
expect(field.getValue(1, "form")).toBe("1");
});
});
@@ -4,8 +4,10 @@ import {
type BaseRelationConfig,
EntityRelation,
EntityRelationAnchor,
ManyToManyRelation,
RelationTypes,
} from "data/relations";
import * as proto from "data/prototype";
class TestEntityRelation extends EntityRelation {
constructor(config?: BaseRelationConfig) {
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
const relation2 = new TestEntityRelation({ required: true });
expect(relation2.required).toBe(true);
});
it("correctly produces the relation name", async () => {
const relation = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"));
expect(relation.getName()).not.toContain(",");
expect(relation.getName()).toBe("mn_apps_organizations");
const relation2 = new ManyToManyRelation(new Entity("apps"), new Entity("organizations"), {
connectionTableMappedName: "appOrganizations",
});
expect(relation2.getName()).toBe("mn_apps_organizations_appOrganizations");
});
});
+1 -21
View File
@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
return { url: "http://127.0.0.1:8080" };
}
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[] = ["debug", "log", "warn"]) {
severities.forEach((severity) => {
console[severity] = () => null;
});
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
return { sql, parameters };
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
const { sql, parameters } = qb.compile();
console.log("$", sqlFormat(sql), "\n[params]", parameters);
console.info("$", sqlFormat(sql), "\n[params]", parameters);
}
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
@@ -1,12 +1,9 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp, type AuthResponse } from "../../src";
import { auth } from "../../src/modules/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection();
afterEach(afterAllCleanup);
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { getDummyConnection } from "../helper";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -66,9 +63,10 @@ const configs = {
};
function createAuthApp() {
const { dummyConnection } = getDummyConnection();
const app = createApp({
connection: dummyConnection,
initialConfig: {
config: {
auth: configs.auth,
},
});
@@ -151,8 +149,8 @@ describe("integration auth", () => {
const { data: users } = await app.em.repository("users").findMany();
expect(users.length).toBe(2);
expect(users[0].email).toBe(configs.users.normal.email);
expect(users[1].email).toBe(configs.users.admin.email);
expect(users[0]?.email).toBe(configs.users.normal.email);
expect(users[1]?.email).toBe(configs.users.admin.email);
});
it("should log you in with API", async () => {
@@ -223,7 +221,7 @@ describe("integration auth", () => {
app.server.get("/get", auth(), async (c) => {
return c.json({
user: c.get("auth").user ?? null,
user: c.get("auth")?.user ?? null,
});
});
app.server.get("/wait", auth(), async (c) => {
@@ -242,7 +240,7 @@ describe("integration auth", () => {
{
await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get");
const data = await res.json();
const data = (await res.json()) as any;
expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any });
}
@@ -1,6 +1,10 @@
import { describe, expect, it } from "bun:test";
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { createApp } from "core/test/utils";
import { Api } from "../../src/Api";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("integration config", () => {
it("should create an entity", async () => {
+39 -5
View File
@@ -6,17 +6,20 @@ 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";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
import { assetsPath, assetsTmpPath } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => {
disableConsoleLog();
registries.media.register("local", StorageLocalAdapter);
});
afterAll(enableConsoleLog);
const path = `${assetsPath}/image.png`;
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
const app = createApp({
initialConfig: {
config: {
media: mergeObject(
{
enabled: true,
@@ -40,9 +43,6 @@ function makeName(ext: string) {
return randomString(10) + "." + ext;
}
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
const app = await makeApp();
@@ -94,4 +94,38 @@ describe("MediaController", () => {
expect(res.status).toBe(413);
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
});
test("audio files", async () => {
const app = await makeApp();
const file = Bun.file(`${assetsPath}/test.mp3`);
const name = makeName("mp3");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
expect(result.data.mime_type).toStartWith("audio/mpeg");
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("text files", async () => {
const app = await makeApp();
const file = Bun.file(`${assetsPath}/test.txt`);
const name = makeName("txt");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file,
});
const result = (await res.json()) as any;
expect(result.data.mime_type).toStartWith("text/plain");
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
});
+37
View File
@@ -71,6 +71,8 @@ describe("media/mime-types", () => {
["application/zip", "zip"],
["text/tab-separated-values", "tsv"],
["application/zip", "zip"],
["application/pdf", "pdf"],
["audio/mpeg", "mp3"],
] as const;
for (const [mime, ext] of tests) {
@@ -88,6 +90,9 @@ describe("media/mime-types", () => {
["image.jpeg", "jpeg"],
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
["file.pdf", "pdf"],
["file.mp3", "mp3"],
["robots.txt", "txt"],
] as const;
for (const [filename, ext] of tests) {
@@ -102,4 +107,36 @@ describe("media/mime-types", () => {
const [, ext] = getRandomizedFilename(file).split(".");
expect(ext).toBe("jpg");
});
test("getRandomizedFilename with body", async () => {
// should keep "pdf"
const [, ext] = getRandomizedFilename(
new File([""], "file.pdf", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("pdf");
{
// no ext, should use "pdf" only for known formats
const [, ext] = getRandomizedFilename(
new File([""], "file", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("pdf");
}
{
// wrong ext, should keep the wrong one
const [, ext] = getRandomizedFilename(
new File([""], "file.what", { type: "application/pdf" }),
).split(".");
expect(ext).toBe("what");
}
{
// txt
const [, ext] = getRandomizedFilename(
new File([""], "file.txt", { type: "text/plain" }),
).split(".");
expect(ext).toBe("txt");
}
});
});
+7 -16
View File
@@ -3,16 +3,13 @@ import { createApp } from "core/test/utils";
import { AuthController } from "../../src/auth/api/AuthController";
import { em, entity, make, text } from "data/prototype";
import { AppAuth, type ModuleBuildContext } from "modules";
import { disableConsoleLog, enableConsoleLog } from "../helper";
import { makeCtx, moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppAuth", () => {
test.only("...", () => {
const auth = new AppAuth({});
console.log(auth.toJSON());
console.log(auth.config);
});
moduleTestSuite(AppAuth);
let ctx: ModuleBuildContext;
@@ -36,11 +33,9 @@ describe("AppAuth", () => {
await auth.build();
const oldConfig = auth.toJSON(true);
//console.log(oldConfig);
await auth.schema().patch("enabled", true);
await auth.build();
const newConfig = auth.toJSON(true);
//console.log(newConfig);
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
});
@@ -66,7 +61,6 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController();
{
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
headers: {
@@ -77,7 +71,6 @@ describe("AppAuth", () => {
password: "12345678",
}),
});
enableConsoleLog();
expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany();
@@ -116,7 +109,6 @@ describe("AppAuth", () => {
const app = new AuthController(auth).getController();
{
disableConsoleLog();
const res = await app.request("/password/register", {
method: "POST",
headers: {
@@ -127,7 +119,6 @@ describe("AppAuth", () => {
password: "12345678",
}),
});
enableConsoleLog();
expect(res.status).toBe(200);
const { data: users } = await ctx.em.repository("users").findMany();
@@ -147,7 +138,7 @@ describe("AppAuth", () => {
test("registers auth middleware for bknd routes only", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
jwt: {
@@ -177,7 +168,7 @@ describe("AppAuth", () => {
test("should allow additional user fields", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
entity_name: "users",
enabled: true,
@@ -201,7 +192,7 @@ describe("AppAuth", () => {
test("ensure user field configs is always correct", async () => {
const app = createApp({
initialConfig: {
config: {
auth: {
enabled: true,
},
+7 -3
View File
@@ -1,13 +1,17 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { createApp } from "core/test/utils";
import { em, entity, text } from "data/prototype";
import { registries } from "modules/registries";
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
import { AppMedia } from "../../src/media/AppMedia";
import { moduleTestSuite } from "./module-test-suite";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("AppMedia", () => {
test.only("...", () => {
test.skip("...", () => {
const media = new AppMedia();
console.log(media.toJSON());
});
@@ -18,7 +22,7 @@ describe("AppMedia", () => {
registries.media.register("local", StorageLocalAdapter);
const app = createApp({
initialConfig: {
config: {
media: {
entity_name: "media",
enabled: true,
@@ -0,0 +1,80 @@
import { it, expect, describe, beforeAll, afterAll } from "bun:test";
import { DbModuleManager } from "modules/db/DbModuleManager";
import { getDummyConnection } from "../helper";
import { TABLE_NAME } from "modules/db/migrations";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("DbModuleManager", () => {
it("should extract secrets", async () => {
const { dummyConnection } = getDummyConnection();
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "test",
},
},
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
await m.save();
});
it("should work with initial secrets", async () => {
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
const m = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "test",
},
});
await m.build();
expect(m.toJSON(true).auth.jwt.secret).toBe("test");
const getSecrets = () =>
db
.selectFrom(TABLE_NAME)
.selectAll()
.where("type", "=", "secrets")
.executeTakeFirst()
.then((r) => r?.json);
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// also after rebuild
await m.build();
await m.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
// and ignore if already present
const m2 = new DbModuleManager(dummyConnection, {
initial: {
auth: {
enabled: true,
jwt: {
secret: "",
},
},
},
secrets: {
"auth.jwt.secret": "something completely different",
},
});
await m2.build();
await m2.save();
expect(await getSecrets()).toEqual({ "auth.jwt.secret": "test" });
});
});
+10 -16
View File
@@ -1,14 +1,19 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { Module } from "modules/Module";
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
import { getDefaultConfig } from "modules/ModuleManager";
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
import { getDummyConnection } from "../helper";
import { s, stripMark } from "core/utils/schema";
import { Connection } from "data/connection/Connection";
import { entity, text } from "data/prototype";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("ModuleManager", async () => {
test("s1: no config, no build", async () => {
const { dummyConnection } = getDummyConnection();
@@ -77,7 +82,6 @@ describe("ModuleManager", async () => {
},
},
} as any;
//const { version, ...json } = mm.toJSON() as any;
const { dummyConnection } = getDummyConnection();
const db = dummyConnection.kysely;
@@ -92,10 +96,6 @@ describe("ModuleManager", async () => {
await mm2.build();
/* console.log({
json,
configs: mm2.configs(),
}); */
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
expect(mm2.configs().data.entities?.test).toBeDefined();
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
@@ -133,7 +133,7 @@ describe("ModuleManager", async () => {
const db = c2.dummyConnection.kysely;
const mm2 = new ModuleManager(c2.dummyConnection, {
initial: { version: version - 1, ...json },
initial: { version: version - 1, ...json } as any,
});
await mm2.syncConfigTable();
await db
@@ -223,8 +223,6 @@ describe("ModuleManager", async () => {
const c = getDummyConnection();
const mm = new ModuleManager(c.dummyConnection);
await mm.build();
console.log("==".repeat(30));
console.log("");
const json = mm.configs();
const c2 = getDummyConnection();
@@ -270,7 +268,6 @@ describe("ModuleManager", async () => {
}
override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value && this.config.value < 0) {
throw new Error("value must be positive, given: " + this.config.value);
}
@@ -291,9 +288,6 @@ describe("ModuleManager", async () => {
}
}
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection);
@@ -1,14 +1,22 @@
import { describe, expect, test } from "bun:test";
import { type InitialModuleConfigs, createApp } from "../../../src";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { App, type InitialModuleConfigs, createApp } from "/";
import { type Kysely, sql } from "kysely";
import { getDummyConnection } from "../../helper";
import v7 from "./samples/v7.json";
import v8 from "./samples/v8.json";
import v8_2 from "./samples/v8-2.json";
import v9 from "./samples/v9.json";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
// app expects migratable config to be present in database
async function createVersionedApp(config: InitialModuleConfigs | any) {
async function createVersionedApp(
config: InitialModuleConfigs | any,
opts?: { beforeCreateApp?: (db: Kysely<any>) => Promise<void> },
) {
const { dummyConnection } = getDummyConnection();
if (!("version" in config)) throw new Error("config must have a version");
@@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
})
.execute();
if (opts?.beforeCreateApp) {
await opts.beforeCreateApp(db);
}
const app = createApp({
connection: dummyConnection,
});
@@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
return app;
}
async function getRawConfig(
app: App,
opts?: { version?: number; types?: ("config" | "diff" | "backup" | "secrets")[] },
) {
const db = app.em.connection.kysely;
return await db
.selectFrom("__bknd")
.selectAll()
.$if(!!opts?.version, (qb) => qb.where("version", "=", opts?.version))
.$if((opts?.types?.length ?? 0) > 0, (qb) => qb.where("type", "in", opts?.types))
.execute();
}
describe("Migrations", () => {
/**
* updated auth strategies to have "enabled" prop
@@ -78,4 +103,30 @@ describe("Migrations", () => {
// @ts-expect-error
expect(app.toJSON(true).server.admin).toBeUndefined();
});
test("migration from 9 to 10", async () => {
expect(v9.version).toBe(9);
const app = await createVersionedApp(v9);
expect(app.version()).toBeGreaterThan(9);
// @ts-expect-error
expect(app.toJSON(true).media.adapter.config.secret_access_key).toBe(
"^^s3.secret_access_key^^",
);
const [config, secrets] = (await getRawConfig(app, {
version: 10,
types: ["config", "secrets"],
})) as any;
expect(config.json.auth.jwt.secret).toBe("");
expect(config.json.media.adapter.config.access_key).toBe("");
expect(config.json.media.adapter.config.secret_access_key).toBe("");
expect(secrets.json["auth.jwt.secret"]).toBe("^^jwt.secret^^");
expect(secrets.json["media.adapter.config.access_key"]).toBe("^^s3.access_key^^");
expect(secrets.json["media.adapter.config.secret_access_key"]).toBe(
"^^s3.secret_access_key^^",
);
});
});
@@ -0,0 +1,612 @@
{
"version": 9,
"server": {
"cors": {
"origin": "*",
"allow_methods": ["GET", "POST", "PATCH", "PUT", "DELETE"],
"allow_headers": [
"Content-Type",
"Content-Length",
"Authorization",
"Accept"
],
"allow_credentials": true
},
"mcp": { "enabled": false, "path": "/api/system/mcp" }
},
"data": {
"basepath": "/api/data",
"default_primary_format": "integer",
"entities": {
"media": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"path": { "type": "text", "config": { "required": true } },
"folder": {
"type": "boolean",
"config": {
"default_value": false,
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"mime_type": { "type": "text", "config": { "required": false } },
"size": { "type": "number", "config": { "required": false } },
"scope": {
"type": "text",
"config": {
"hidden": true,
"fillable": ["create"],
"required": false
}
},
"etag": { "type": "text", "config": { "required": false } },
"modified_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"reference": { "type": "text", "config": { "required": false } },
"entity_id": { "type": "number", "config": { "required": false } },
"metadata": { "type": "json", "config": { "required": false } }
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"users": {
"type": "system",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"email": { "type": "text", "config": { "required": true } },
"strategy": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["password"] },
"required": true,
"hidden": ["update", "form"],
"fillable": ["create"]
}
},
"strategy_value": {
"type": "text",
"config": {
"fillable": ["create"],
"hidden": ["read", "table", "update", "form"],
"required": true
}
},
"role": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["admin", "guest"] },
"required": false
}
},
"age": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["18-24", "25-34", "35-44", "45-64", "65+"]
},
"required": false
}
},
"height": { "type": "number", "config": { "required": false } },
"gender": {
"type": "enum",
"config": {
"options": { "type": "strings", "values": ["male", "female"] },
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "asc" }
},
"avatars": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"identifier": { "type": "text", "config": { "required": false } },
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"started_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"input": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "avatars"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"tryons": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"completed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"avatars_id": {
"type": "relation",
"config": {
"label": "Avatars",
"required": false,
"reference": "avatars",
"target": "avatars",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"output": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "tryons",
"max_items": 1
}
},
"products_id": {
"type": "relation",
"config": {
"label": "Products",
"required": false,
"reference": "products",
"target": "products",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"payload": {
"type": "json",
"config": { "required": false, "hidden": ["table"] }
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"products": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"enabled": { "type": "boolean", "config": { "required": false } },
"title": { "type": "text", "config": { "required": false } },
"url": { "type": "text", "config": { "required": false } },
"image": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "products",
"max_items": 1
}
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"garment_type": {
"type": "enum",
"config": {
"options": {
"type": "strings",
"values": ["auto", "tops", "bottoms", "one-pieces"]
},
"required": false
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sites": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": {
"format": "integer",
"fillable": false,
"required": false
}
},
"origin": {
"type": "text",
"config": {
"pattern": "^(https?):\\/\\/([a-zA-Z0-9.-]+)(:\\d+)?$",
"required": true
}
},
"name": { "type": "text", "config": { "required": false } },
"active": { "type": "boolean", "config": { "required": false } },
"logo": {
"type": "media",
"config": {
"required": false,
"fillable": ["update"],
"hidden": false,
"mime_types": [],
"virtual": true,
"entity": "sites",
"max_items": 1
}
},
"instructions": {
"type": "text",
"config": {
"html_config": {
"element": "textarea",
"props": { "rows": "2" }
},
"required": false,
"hidden": ["table"]
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
},
"sessions": {
"type": "regular",
"fields": {
"id": {
"type": "primary",
"config": { "format": "uuid", "fillable": false, "required": false }
},
"created_at": {
"type": "date",
"config": { "type": "datetime", "required": true }
},
"claimed_at": {
"type": "date",
"config": { "type": "datetime", "required": false }
},
"url": { "type": "text", "config": { "required": false } },
"sites_id": {
"type": "relation",
"config": {
"label": "Sites",
"required": false,
"reference": "sites",
"target": "sites",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
},
"users_id": {
"type": "relation",
"config": {
"label": "Users",
"required": false,
"reference": "users",
"target": "users",
"target_field": "id",
"target_field_type": "integer",
"on_delete": "set null"
}
}
},
"config": { "sort_field": "id", "sort_dir": "desc" }
}
},
"relations": {
"poly_avatars_media_input": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "input" }
},
"poly_avatars_media_output": {
"type": "poly",
"source": "avatars",
"target": "media",
"config": { "mappedBy": "output" }
},
"n1_avatars_users": {
"type": "n:1",
"source": "avatars",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_avatars": {
"type": "n:1",
"source": "tryons",
"target": "avatars",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_tryons_users": {
"type": "n:1",
"source": "tryons",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_tryons_media_output": {
"type": "poly",
"source": "tryons",
"target": "media",
"config": { "mappedBy": "output", "targetCardinality": 1 }
},
"poly_products_media_image": {
"type": "poly",
"source": "products",
"target": "media",
"config": { "mappedBy": "image", "targetCardinality": 1 }
},
"n1_tryons_products": {
"type": "n:1",
"source": "tryons",
"target": "products",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"poly_sites_media_logo": {
"type": "poly",
"source": "sites",
"target": "media",
"config": { "mappedBy": "logo", "targetCardinality": 1 }
},
"n1_sessions_sites": {
"type": "n:1",
"source": "sessions",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_sessions_users": {
"type": "n:1",
"source": "sessions",
"target": "users",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
},
"n1_products_sites": {
"type": "n:1",
"source": "products",
"target": "sites",
"config": {
"mappedBy": "",
"inversedBy": "",
"required": false,
"with_limit": 5
}
}
},
"indices": {
"idx_unique_media_path": {
"entity": "media",
"fields": ["path"],
"unique": true
},
"idx_media_reference": {
"entity": "media",
"fields": ["reference"],
"unique": false
},
"idx_media_entity_id": {
"entity": "media",
"fields": ["entity_id"],
"unique": false
},
"idx_unique_users_email": {
"entity": "users",
"fields": ["email"],
"unique": true
},
"idx_users_strategy": {
"entity": "users",
"fields": ["strategy"],
"unique": false
},
"idx_users_strategy_value": {
"entity": "users",
"fields": ["strategy_value"],
"unique": false
},
"idx_sites_origin_active": {
"entity": "sites",
"fields": ["origin", "active"],
"unique": false
},
"idx_sites_active": {
"entity": "sites",
"fields": ["active"],
"unique": false
},
"idx_products_url": {
"entity": "products",
"fields": ["url"],
"unique": false
}
}
},
"auth": {
"enabled": true,
"basepath": "/api/auth",
"entity_name": "users",
"allow_register": true,
"jwt": {
"secret": "^^jwt.secret^^",
"alg": "HS256",
"expires": 999999999,
"issuer": "issuer",
"fields": ["id", "email", "role"]
},
"cookie": {
"path": "/",
"sameSite": "none",
"secure": true,
"httpOnly": true,
"expires": 604800,
"partitioned": false,
"renew": true,
"pathSuccess": "/admin",
"pathLoggedOut": "/"
},
"strategies": {
"password": {
"enabled": true,
"type": "password",
"config": { "hashing": "sha256" }
}
},
"guard": { "enabled": false },
"roles": {
"admin": { "implicit_allow": true },
"guest": { "is_default": true }
}
},
"media": {
"enabled": true,
"basepath": "/api/media",
"entity_name": "media",
"storage": { "body_max_size": 0 },
"adapter": {
"type": "s3",
"config": {
"access_key": "^^s3.access_key^^",
"secret_access_key": "^^s3.secret_access_key^^",
"url": "https://1234.r2.cloudflarestorage.com/bucket-name"
}
}
},
"flows": { "basepath": "/api/flows", "flows": {} }
}
+14 -18
View File
@@ -1,24 +1,19 @@
import pkg from "./package.json" with { type: "json" };
import c from "picocolors";
import { formatNumber } from "bknd/utils";
import * as esbuild from "esbuild";
if (process.env.DEBUG) {
await esbuild.build({
entryPoints: ["./src/cli/index.ts"],
outdir: "./dist/cli",
platform: "node",
minify: false,
format: "esm",
bundle: true,
external: ["jsonv-ts", "jsonv-ts/*"],
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
},
});
process.exit(0);
}
const deps = Object.keys(pkg.dependencies);
const external = [
"jsonv-ts/*",
"wrangler",
"bknd",
"bknd/*",
"@vitejs/plugin-react",
"vite",
"@tailwindcss/vite",
"@cloudflare/vite-plugin",
...deps,
];
const result = await Bun.build({
entrypoints: ["./src/cli/index.ts"],
@@ -26,7 +21,8 @@ const result = await Bun.build({
outdir: "./dist/cli",
env: "PUBLIC_*",
minify: true,
external: ["jsonv-ts", "jsonv-ts/*"],
banner: `const __originalLog=console.log;console.log=(...o)=>{const n=o[0];"string"==typeof n&&n.includes("[dotenv@")||__originalLog.apply(console,o)};`,
external,
define: {
__isDev: "0",
__version: JSON.stringify(pkg.version),
+19 -1
View File
@@ -85,12 +85,18 @@ async function buildApi() {
sourcemap,
watch,
define,
entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"],
entry: [
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
removeNodeProtocol: false,
format: ["esm"],
splitting: false,
loader: {
@@ -180,6 +186,9 @@ async function buildUiElements() {
outDir: "dist/ui/elements",
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
@@ -223,6 +232,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
removeNodeProtocol: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
@@ -252,10 +262,17 @@ async function buildAdapters() {
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}),
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
@@ -270,6 +287,7 @@ async function buildAdapters() {
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
-6
View File
@@ -1,6 +0,0 @@
[install]
#registry = "http://localhost:4873"
[test]
coverageSkipTestFiles = true
console.depth = 10
+7 -4
View File
@@ -3,11 +3,14 @@ import { createApp } from "bknd/adapter/bun";
async function generate() {
console.info("Generating MCP documentation...");
const app = await createApp({
initialConfig: {
connection: {
url: ":memory:",
},
config: {
server: {
mcp: {
enabled: true,
path: "/mcp",
path: "/mcp2",
},
},
auth: {
@@ -25,9 +28,9 @@ async function generate() {
},
});
await app.build();
await app.getMcpClient().ping();
const res = await app.server.request("/mcp?explain=1");
const { tools, resources } = await res.json();
const { tools, resources } = app.mcp!.toJSON();
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
console.info("MCP documentation generated.");
+73 -50
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.17.2",
"version": "0.20.0-rc.1",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -13,7 +13,7 @@
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
"packageManager": "bun@1.2.19",
"packageManager": "bun@1.3.3",
"engines": {
"node": ">=22.13"
},
@@ -30,7 +30,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run test:node && VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
"prepublishOnly": "bun run types && bun run test && bun run test:node && NODE_NO_WARNINGS=1 VITE_DB_URL=:memory: bun run test:e2e && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
@@ -40,7 +40,7 @@
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"test:vitest:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"test:e2e:adapters": "bun run e2e/adapters.ts",
"test:e2e:adapters": "NODE_NO_WARNINGS=1 bun run e2e/adapters.ts",
"test:e2e:ui": "VITE_DB_URL=:memory: playwright test --ui",
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
@@ -49,91 +49,104 @@
"license": "FSL-1.1-MIT",
"dependencies": {
"@cfworker/json-schema": "^4.1.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@hello-pangea/dnd": "^18.0.1",
"@hono/swagger-ui": "^0.5.1",
"@hono/swagger-ui": "^0.5.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@tanstack/react-form": "^1.0.5",
"@uiw/react-codemirror": "^4.23.10",
"@xyflow/react": "^12.4.4",
"@uiw/react-codemirror": "^4.25.2",
"@xyflow/react": "^12.9.2",
"aws4fetch": "^1.0.20",
"bcryptjs": "^3.0.2",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.0.8",
"hono": "4.8.3",
"bcryptjs": "^3.0.3",
"dayjs": "^1.11.19",
"fast-xml-parser": "^5.3.1",
"hono": "4.10.4",
"json-schema-library": "10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"jsonv-ts": "0.8.2",
"kysely": "0.27.6",
"jsonv-ts": "^0.10.1",
"kysely": "0.28.8",
"lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2",
"radix-ui": "^1.1.3",
"picocolors": "^1.1.1",
"swr": "^2.3.3"
"radix-ui": "^1.1.3",
"swr": "^2.3.6",
"use-sync-external-store": "^1.6.0",
"zustand": "^4"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.758.0",
"@bluwy/giget-core": "^0.1.2",
"@aws-sdk/client-s3": "^3.922.0",
"@bluwy/giget-core": "^0.1.6",
"@clack/prompts": "^0.11.0",
"@cloudflare/vitest-pool-workers": "^0.8.38",
"@cloudflare/workers-types": "^4.20250606.0",
"@cloudflare/vite-plugin": "^1.15.3",
"@cloudflare/vitest-pool-workers": "^0.10.4",
"@cloudflare/workers-types": "^4.20251014.0",
"@dagrejs/dagre": "^1.1.4",
"@hono/vite-dev-server": "^0.19.1",
"@hookform/resolvers": "^4.1.3",
"@libsql/client": "^0.15.9",
"@hono/vite-dev-server": "^0.23.0",
"@hookform/resolvers": "^5.2.2",
"@libsql/client": "^0.15.15",
"@mantine/modals": "^7.17.1",
"@mantine/notifications": "^7.17.1",
"@playwright/test": "^1.51.1",
"@playwright/test": "^1.56.1",
"@rjsf/core": "5.22.2",
"@rjsf/utils": "5.22.0",
"@standard-schema/spec": "^1.0.0",
"@tabler/icons-react": "3.18.0",
"@tailwindcss/postcss": "^4.0.12",
"@tailwindcss/vite": "^4.0.12",
"@tabler/icons-react": "3.35.0",
"@tailwindcss/postcss": "^4.1.16",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-store": "^0.8.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.13.10",
"@types/node": "^24.10.0",
"@types/pg": "^8.15.6",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.0.9",
"@vitejs/plugin-react": "^5.1.0",
"@vitest/coverage-v8": "3.0.9",
"autoprefixer": "^10.4.21",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
"commander": "^14.0.2",
"dotenv": "^17.2.3",
"jotai": "^2.12.2",
"jsdom": "^26.0.0",
"kysely-d1": "^0.3.0",
"jsdom": "^26.1.0",
"kysely-generic-sqlite": "^1.2.1",
"kysely-postgres-js": "^2.0.0",
"libsql": "^0.5.22",
"libsql-stateless-easy": "^1.8.0",
"open": "^10.1.0",
"miniflare": "^4.20251011.2",
"open": "^10.2.0",
"openapi-types": "^12.1.3",
"pg": "^8.16.3",
"postcss": "^8.5.3",
"postcss-preset-mantine": "^1.17.0",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"posthog-js-lite": "^3.4.2",
"postgres": "^3.4.7",
"posthog-js-lite": "^3.6.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "5.2.1",
"react-json-view-lite": "^2.4.1",
"sql-formatter": "^15.4.11",
"react-hook-form": "^7.66.0",
"react-icons": "5.5.0",
"react-json-view-lite": "^2.5.0",
"sql-formatter": "^15.6.10",
"sqlocal": "^0.16.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.12",
"tailwindcss": "^4.1.16",
"tailwindcss-animate": "^1.0.7",
"tsc-alias": "^1.8.11",
"tsup": "^8.4.0",
"tsx": "^4.19.3",
"uuid": "^11.1.0",
"vite": "^6.3.5",
"tsc-alias": "^1.8.16",
"tsup": "^8.5.0",
"tsx": "^4.20.6",
"uuid": "^13.0.0",
"vite": "^7.1.12",
"vite-plugin-circular-dependency": "^0.5.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.9",
"wouter": "^3.6.0"
"vitest": "3.0.9",
"wouter": "^3.7.1",
"wrangler": "^4.52.1"
},
"optionalDependencies": {
"@hono/node-server": "^1.14.3"
"@hono/node-server": "^1.19.6"
},
"peerDependencies": {
"react": ">=19",
@@ -178,6 +191,11 @@
"import": "./dist/plugins/index.js",
"require": "./dist/plugins/index.js"
},
"./modes": {
"types": "./dist/types/modes/index.d.ts",
"import": "./dist/modes/index.js",
"require": "./dist/modes/index.js"
},
"./adapter/sqlite": {
"types": "./dist/types/adapter/sqlite/edge.d.ts",
"import": {
@@ -241,6 +259,11 @@
"import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.js"
},
"./adapter/browser": {
"types": "./dist/types/adapter/browser/index.d.ts",
"import": "./dist/adapter/browser/index.js",
"require": "./dist/adapter/browser/index.js"
},
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
+47 -33
View File
@@ -40,6 +40,7 @@ export type ApiOptions = {
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
credentials?: RequestCredentials;
} & (
| {
token?: string;
@@ -60,14 +61,14 @@ export class Api {
private token?: string;
private user?: TApiUser;
private verified = false;
private token_transport: "header" | "cookie" | "none" = "header";
public token_transport: "header" | "cookie" | "none" = "header";
public system!: SystemApi;
public data!: DataApi;
public auth!: AuthApi;
public media!: MediaApi;
constructor(private options: ApiOptions = {}) {
constructor(public options: ApiOptions = {}) {
// only mark verified if forced
this.verified = options.verified === true;
@@ -129,29 +130,45 @@ export class Api {
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
this.token_transport = "header";
this.updateToken(token ? String(token) : undefined);
this.updateToken(token ? String(token) : undefined, {
verified: true,
trigger: false,
});
});
}
}
/**
* Make storage async to allow async storages even if sync given
* @private
*/
private get storage() {
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
const storage = this.options.storage;
return new Proxy(
{},
{
get(_, prop) {
return (...args: any[]) => {
const response = storage ? storage[prop](...args) : undefined;
if (response instanceof Promise) {
return response;
}
return {
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
then: (fn) => fn(response),
};
};
},
},
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);
},
};
) as any;
}
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
updateToken(
token?: string,
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
) {
this.token = token;
this.verified = false;
this.verified = opts?.verified === true;
if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
@@ -159,21 +176,22 @@ export class Api {
this.user = undefined;
}
const emit = () => {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
}
};
if (this.storage) {
const key = this.tokenKey;
if (token) {
this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
this.storage.setItem(key, token).then(emit);
} else {
this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
this.storage.removeItem(key).then(emit);
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
emit();
}
}
@@ -182,6 +200,7 @@ export class Api {
private markAuthVerified(verfied: boolean) {
this.verified = verfied;
this.options.onAuthStateChange?.(this.getAuthState());
return this;
}
@@ -208,11 +227,6 @@ export class Api {
}
async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try {
const { ok, data } = await this.auth.me();
const user = data?.user;
@@ -221,10 +235,10 @@ export class Api {
}
this.user = user;
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
} finally {
this.markAuthVerified(true);
}
}
@@ -239,6 +253,7 @@ export class Api {
headers: this.options.headers,
token_transport: this.token_transport,
verbose: this.options.verbose,
credentials: this.options.credentials,
});
}
@@ -257,10 +272,9 @@ export class Api {
this.auth = new AuthApi(
{
...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
onTokenUpdate: (token, verified) => {
this.updateToken(token, { rebuild: true, verified, trigger: true });
this.options.auth?.onTokenUpdate?.(token);
},
},
+45 -26
View File
@@ -5,17 +5,17 @@ import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import type { Hono } from "hono";
import {
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
ModuleManager,
type ModuleBuildContext,
type ModuleManagerOptions,
} from "modules/ModuleManager";
import { DbModuleManager } from "modules/db/DbModuleManager";
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
import type { MaybePromise } from "core/types";
import type { MaybePromise, PartialRec } from "core/types";
import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers";
@@ -93,20 +93,23 @@ export type AppOptions = {
email?: IEmailDriver;
cache?: ICacheDriver;
};
mode?: "db" | "code";
readonly?: boolean;
};
export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs;
config?: PartialRec<ModuleConfigs>;
options?: AppOptions;
};
export type AppConfig = InitialModuleConfigs;
export type AppConfig = { version: number } & ModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
export class App<
C extends Connection = Connection,
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
Options extends AppOptions = AppOptions,
> {
static readonly Events = AppEvents;
modules: ModuleManager;
@@ -121,8 +124,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
constructor(
public connection: C,
_initialConfig?: InitialModuleConfigs,
private options?: Options,
_config?: Config,
public options?: Options,
) {
this.drivers = options?.drivers ?? {};
@@ -134,9 +137,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.plugins.set(config.name, config);
}
this.runPlugins("onBoot");
this.modules = new ModuleManager(connection, {
// use db manager by default
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
this.modules = new Manager(connection, {
...(options?.manager ?? {}),
initial: _initialConfig,
initial: _config,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
@@ -145,6 +152,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.modules.ctx().emgr.registerEvents(AppEvents);
}
get mode() {
return this.options?.mode ?? "db";
}
isReadOnly() {
return Boolean(this.mode === "code" || this.options?.readonly);
}
get emgr() {
return this.modules.ctx().emgr;
}
@@ -175,7 +190,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return results as any;
}
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
// prevent multiple concurrent builds
if (this._building) {
while (this._building) {
@@ -188,7 +203,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch });
await this.modules.build();
const { guard } = this.modules.ctx();
@@ -215,10 +230,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = false;
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() {
return this.modules.server;
}
@@ -232,6 +243,9 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
}
get fetch(): Hono["fetch"] {
if (!this.isBuilt()) {
console.error("App is not built yet, run build() first");
}
return this.server.fetch as any;
}
@@ -280,6 +294,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return this.module.auth.createUser(p);
}
// @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners
getApi(options?: LocalApiOptions) {
const fetcher = this.server.request as typeof fetch;
if (options && options instanceof Request) {
@@ -290,13 +305,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
}
getMcpClient() {
if (!this.mcp) {
const config = this.modules.get("server").config.mcp;
if (!config.enabled) {
throw new Error("MCP is not enabled");
}
const mcpPath = this.modules.get("server").config.mcp.path;
const url = new URL(config.path, "http://localhost").toString();
return new McpClient({
url: "http://localhost" + mcpPath,
url,
fetch: this.server.request,
});
}
@@ -364,11 +380,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
if (results.length > 0) {
for (const { name, result } of results) {
if (result) {
$console.log(`[Plugin:${name}] schema`);
ctx.helper.ensureSchema(result);
if (ctx.flags.sync_required) {
$console.log(`[Plugin:${name}] schema, sync required`);
}
}
}
}
await this.options?.manager?.onModulesBuilt?.(ctx);
}
}
@@ -377,5 +396,5 @@ export function createApp(config: CreateAppConfig = {}) {
throw new Error("Invalid connection");
}
return new App(config.connection, config.initialConfig, config.options);
return new App(config.connection, config.config, config.options);
}
+14 -21
View File
@@ -1,6 +1,7 @@
import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { BkndConfig, DefaultArgs } from "./index";
import type { App } from "App";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
export function adapterTestSuite<
Config extends BkndConfig = BkndConfig,
@@ -13,24 +14,17 @@ export function adapterTestSuite<
label = "app",
overrides = {},
}: {
makeApp: (
config: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
makeApp: (config: Config, args?: Args) => Promise<App>;
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
label?: string;
overrides?: {
dbUrl?: string;
};
},
) {
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
const { test, expect, mock, beforeAll, afterAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
@@ -39,7 +33,7 @@ export function adapterTestSuite<
const config = {
app: (env) => ({
connection: { url: env.url },
initialConfig: {
config: {
server: { cors: { origin: env.origin } },
},
}),
@@ -53,11 +47,10 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:",
origin: "localhost",
} as any,
{ force: false, id },
);
expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost");
expect(beforeBuild).toHaveBeenCalledTimes(1);
expect(beforeBuild).toHaveBeenCalledTimes(2);
expect(onBuilt).toHaveBeenCalledTimes(1);
});
@@ -68,8 +61,8 @@ export function adapterTestSuite<
return { res, data };
};
test("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { force: false, id });
/* test.skip("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
@@ -77,14 +70,14 @@ export function adapterTestSuite<
expect(data.server.cors.origin).toEqual("localhost");
});
test("creates fresh & responds to api config", async () => {
test.skip("creates fresh & responds to api config", async () => {
// set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
});
}); */
}
}
+1 -1
View File
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
});
});
+5 -7
View File
@@ -1,4 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = {
@@ -8,18 +8,16 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts: FrameworkOptions = {},
args: Env = import.meta.env as Env,
) {
return await createFrameworkApp(config, args ?? import.meta.env, opts);
return await createFrameworkApp(config, args);
}
export function serve<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = import.meta.env as Env,
) {
return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
return (await getApp(config, args)).fetch(fnArgs.request);
};
}
+2 -5
View File
@@ -1,7 +1,7 @@
import type { App } from "bknd";
import { handle } from "hono/aws-lambda";
import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
...additional,
},
args ?? process.env,
opts,
);
}
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => {
const app = await createApp(config, args, opts);
const app = await createApp(config, args);
return await handle(app.server)(event);
};
}
+2 -2
View File
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o);
makeHandler: (c, a) => async (request: Request) => {
const app = await awsLambda.createApp(c, a);
return app.fetch(request);
},
});
+153
View File
@@ -0,0 +1,153 @@
import {
createContext,
lazy,
Suspense,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import { checksum } from "bknd/utils";
import { App, registries, sqlocal, type BkndConfig } from "bknd";
import { Route, Router, Switch } from "wouter";
import { ClientProvider } from "bknd/client";
import { SQLocalKysely } from "sqlocal/kysely";
import type { ClientConfig, DatabasePath } from "sqlocal";
import { OpfsStorageAdapter } from "bknd/adapter/browser";
import type { BkndAdminConfig } from "bknd/ui";
const Admin = lazy(() =>
Promise.all([
import("bknd/ui"),
// @ts-ignore
import("bknd/dist/styles.css"),
]).then(([mod]) => ({
default: mod.Admin,
})),
);
function safeViewTransition(fn: () => void) {
if (document.startViewTransition) {
document.startViewTransition(fn);
} else {
fn();
}
}
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
BkndConfig<Args>,
"connection" | "app"
> & {
adminConfig?: BkndAdminConfig;
connection?: ClientConfig | DatabasePath;
};
export type BkndBrowserAppProps = {
children: ReactNode;
header?: ReactNode;
loading?: ReactNode;
notFound?: ReactNode;
} & BrowserBkndConfig;
const BkndBrowserAppContext = createContext<{
app: App;
hash: string;
}>(undefined!);
export function BkndBrowserApp({
children,
adminConfig,
header,
loading,
notFound,
...config
}: BkndBrowserAppProps) {
const [app, setApp] = useState<App | undefined>(undefined);
const [hash, setHash] = useState<string>("");
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
async function onBuilt(app: App) {
safeViewTransition(async () => {
setApp(app);
setHash(await checksum(app.toJSON()));
});
}
useEffect(() => {
setup({ ...config, adminConfig })
.then((app) => onBuilt(app as any))
.catch(console.error);
}, []);
if (!app) {
return (
loading ?? (
<Center>
<span style={{ opacity: 0.2 }}>Loading...</span>
</Center>
)
);
}
return (
<BkndBrowserAppContext.Provider value={{ app, hash }}>
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
{header}
<Router key={hash}>
<Switch>
{children}
<Route path={adminRoutePath}>
<Suspense>
<Admin config={adminConfig} />
</Suspense>
</Route>
<Route path="*">
{notFound ?? (
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
)}
</Route>
</Switch>
</Router>
</ClientProvider>
</BkndBrowserAppContext.Provider>
);
}
export function useApp() {
return useContext(BkndBrowserAppContext);
}
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
<div
{...props}
style={{
width: "100%",
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
...(props.style ?? {}),
}}
/>
);
let initialized = false;
async function setup(config: BrowserBkndConfig = {}) {
if (initialized) return;
initialized = true;
registries.media.register("opfs", OpfsStorageAdapter);
const app = App.create({
...config,
// @ts-ignore
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
});
await config.beforeBuild?.(app);
await app.build({ sync: true });
await config.onBuilt?.(app);
return app;
}
@@ -0,0 +1,34 @@
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
// @ts-ignore
import { assetsPath } from "../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
describe("OpfsStorageAdapter", async () => {
let mockRoot: MockFileSystemDirectoryHandle;
let testSuiteAdapter: OpfsStorageAdapter;
const _mock = spyOn(global, "navigator");
beforeAll(() => {
// mock navigator.storage.getDirectory()
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
const mockNavigator = {
storage: {
getDirectory: vi.fn().mockResolvedValue(mockRoot),
},
};
// @ts-ignore
_mock.mockReturnValue(mockNavigator);
testSuiteAdapter = new OpfsStorageAdapter();
});
afterAll(() => {
_mock.mockRestore();
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
});
@@ -0,0 +1,265 @@
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
import { StorageAdapter, guessMimeType } from "bknd";
import { parse, s, isFile, isBlob } from "bknd/utils";
export const opfsAdapterConfig = s.object(
{
root: s.string({ default: "" }).optional(),
},
{
title: "OPFS",
description: "Origin Private File System storage",
additionalProperties: false,
},
);
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
/**
* Storage adapter for OPFS (Origin Private File System)
* Provides browser-based file storage using the File System Access API
*/
export class OpfsStorageAdapter extends StorageAdapter {
private config: OpfsAdapterConfig;
private rootPromise: Promise<FileSystemDirectoryHandle>;
constructor(config: Partial<OpfsAdapterConfig> = {}) {
super();
this.config = parse(opfsAdapterConfig, config);
this.rootPromise = this.initializeRoot();
}
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
if (!this.config.root) {
return opfsRoot;
}
// navigate to or create nested directory structure
const parts = this.config.root.split("/").filter(Boolean);
let current = opfsRoot;
for (const part of parts) {
current = await current.getDirectoryHandle(part, { create: true });
}
return current;
}
getSchema() {
return opfsAdapterConfig;
}
getName(): string {
return "opfs";
}
async listObjects(prefix?: string): Promise<FileListObject[]> {
const root = await this.rootPromise;
const files: FileListObject[] = [];
for await (const [name, handle] of root.entries()) {
if (handle.kind === "file") {
if (!prefix || name.startsWith(prefix)) {
const file = await (handle as FileSystemFileHandle).getFile();
files.push({
key: name,
last_modified: new Date(file.lastModified),
size: file.size,
});
}
}
}
return files;
}
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
// wrap the hex string in quotes for ETag format
return `"${hashHex}"`;
}
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) {
throw new Error("Body is empty");
}
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
try {
let contentBuffer: ArrayBuffer;
if (isFile(body)) {
contentBuffer = await body.arrayBuffer();
await writable.write(contentBuffer);
} else if (body instanceof ReadableStream) {
const chunks: Uint8Array[] = [];
const reader = body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
await writable.write(value);
}
} finally {
reader.releaseLock();
}
// compute total size and combine chunks for etag
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combined = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
contentBuffer = combined.buffer;
} else if (isBlob(body)) {
contentBuffer = await (body as Blob).arrayBuffer();
await writable.write(contentBuffer);
} else {
// body is ArrayBuffer or ArrayBufferView
if (ArrayBuffer.isView(body)) {
const view = body as ArrayBufferView;
contentBuffer = view.buffer.slice(
view.byteOffset,
view.byteOffset + view.byteLength,
) as ArrayBuffer;
} else {
contentBuffer = body as ArrayBuffer;
}
await writable.write(body);
}
await writable.close();
return await this.computeEtagFromArrayBuffer(contentBuffer);
} catch (error) {
await writable.abort();
throw error;
}
}
async deleteObject(key: string): Promise<void> {
try {
const root = await this.rootPromise;
await root.removeEntry(key);
} catch {
// file doesn't exist, which is fine
}
}
async objectExists(key: string): Promise<boolean> {
try {
const root = await this.rootPromise;
await root.getFileHandle(key);
return true;
} catch {
return false;
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
const fileSize = file.size;
const mimeType = guessMimeType(key);
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const arrayBuffer = await file.arrayBuffer();
const chunk = arrayBuffer.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.byteLength.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// normal request - return entire file
const content = await file.arrayBuffer();
responseHeaders.set("Content-Length", content.byteLength.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch {
// handle file reading errors
return new Response("", { status: 404 });
}
}
getObjectUrl(_key: string): string {
throw new Error("Method not implemented.");
}
async getObjectMeta(key: string): Promise<FileMeta> {
const root = await this.rootPromise;
const fileHandle = await root.getFileHandle(key);
const file = await fileHandle.getFile();
return {
type: guessMimeType(key) || "application/octet-stream",
size: file.size,
};
}
toJSON(_secrets?: boolean) {
return {
type: this.getName(),
config: this.config,
};
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./OpfsStorageAdapter";
export * from "./BkndBrowserApp";
+136
View File
@@ -0,0 +1,136 @@
// mock OPFS API for testing
class MockFileSystemFileHandle {
kind: "file" = "file";
name: string;
private content: ArrayBuffer;
private lastModified: number;
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
this.name = name;
this.content = content;
this.lastModified = Date.now();
}
async getFile(): Promise<File> {
return new File([this.content], this.name, {
lastModified: this.lastModified,
type: this.guessMimeType(),
});
}
async createWritable(): Promise<FileSystemWritableFileStream> {
const handle = this;
return {
async write(data: any) {
if (data instanceof ArrayBuffer) {
handle.content = data;
} else if (ArrayBuffer.isView(data)) {
handle.content = data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength,
) as ArrayBuffer;
} else if (data instanceof Blob) {
handle.content = await data.arrayBuffer();
}
handle.lastModified = Date.now();
},
async close() {},
async abort() {},
async seek(_position: number) {},
async truncate(_size: number) {},
} as FileSystemWritableFileStream;
}
private guessMimeType(): string {
const ext = this.name.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
txt: "text/plain",
json: "application/json",
pdf: "application/pdf",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
}
export class MockFileSystemDirectoryHandle {
kind: "directory" = "directory";
name: string;
private files: Map<string, MockFileSystemFileHandle> = new Map();
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
constructor(name: string = "root") {
this.name = name;
}
async getFileHandle(
name: string,
options?: FileSystemGetFileOptions,
): Promise<FileSystemFileHandle> {
if (this.files.has(name)) {
return this.files.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemFileHandle(name);
this.files.set(name, handle);
return handle as any;
}
throw new Error(`File not found: ${name}`);
}
async getDirectoryHandle(
name: string,
options?: FileSystemGetDirectoryOptions,
): Promise<FileSystemDirectoryHandle> {
if (this.directories.has(name)) {
return this.directories.get(name) as any;
}
if (options?.create) {
const handle = new MockFileSystemDirectoryHandle(name);
this.directories.set(name, handle);
return handle as any;
}
throw new Error(`Directory not found: ${name}`);
}
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
this.files.delete(name);
this.directories.delete(name);
}
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
for (const [name, handle] of this.files) {
yield [name, handle as any];
}
for (const [name, handle] of this.directories) {
yield [name, handle as any];
}
}
async *keys(): AsyncIterableIterator<string> {
for (const name of this.files.keys()) {
yield name;
}
for (const name of this.directories.keys()) {
yield name;
}
}
async *values(): AsyncIterableIterator<FileSystemHandle> {
for (const handle of this.files.values()) {
yield handle as any;
}
for (const handle of this.directories.values()) {
yield handle as any;
}
}
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
return this.entries();
}
}
+15 -18
View File
@@ -1,19 +1,16 @@
/// <reference types="bun-types" />
import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> &
Omit<Bun.Serve.Options<undefined, string>, "fetch">;
export async function createApp<Env = BunEnv>(
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = Bun.env as Env,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
@@ -27,20 +24,18 @@ export async function createApp<Env = BunEnv>(
}),
...config,
},
args ?? (process.env as Env),
opts,
args,
);
}
export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = Bun.env as Env,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args);
}
return app.fetch(req);
};
@@ -48,36 +43,38 @@ export function createHandler<Env = BunEnv>(
export function serve<Env = BunEnv>(
{
app,
distPath,
connection,
initialConfig,
config: _config,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
serveStatic,
beforeBuild,
...serveOptions
}: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = Bun.env as Env,
) {
Bun.serve({
...serveOptions,
...(serveOptions as any),
port,
fetch: createHandler(
{
app,
connection,
initialConfig,
config: _config,
options,
onBuilt,
buildConfig,
adminOptions,
distPath,
serveStatic,
beforeBuild,
},
args,
opts,
),
});
@@ -1,8 +1,9 @@
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
import { describe, test, mock, expect } from "bun:test";
import { Database } from "bun:sqlite";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
}),
rawDialectDetails: [],
});
test("onCreateConnection", async () => {
const called = mock(() => null);
const conn = bunSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(Database);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(Database);
expect(called).toHaveBeenCalledTimes(1);
});
});
@@ -1,40 +1,53 @@
import { Database } from "bun:sqlite";
import { genericSqlite, type GenericSqliteConnection } from "bknd";
import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { omitKeys } from "bknd/utils";
export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = {
database: Database;
};
export type BunSqliteConnectionConfig = Omit<
GenericSqliteConnectionConfig<Database>,
"name" | "supports"
> &
({ database?: Database; url?: never } | { url?: string; database?: never });
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
let db: Database;
export function bunSqlite(config?: BunSqliteConnectionConfig) {
let db: Database | undefined;
if (config) {
if ("database" in config) {
if ("database" in config && config.database) {
db = config.database;
} else {
} else if (config.url) {
db = new Database(config.url);
}
} else {
}
if (!db) {
db = new Database(":memory:");
}
return genericSqlite("bun-sqlite", db, (utils) => {
//const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db.prepare(sql);
return genericSqlite(
"bun-sqlite",
db,
(utils) => {
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(),
};
});
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(),
};
},
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
);
}
+8
View File
@@ -1,3 +1,11 @@
export * from "./bun.adapter";
export * from "../node/storage";
export * from "./connection/BunSqliteConnection";
export async function writer(path: string, content: string) {
await Bun.write(path, content);
}
export async function reader(path: string) {
return await Bun.file(path).text();
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test";
export const bunTestRunner = {
describe,
@@ -8,4 +8,5 @@ export const bunTestRunner = {
beforeEach,
afterEach,
afterAll,
beforeAll,
};
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
const staticConfig = await makeConfig(
{
connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
config: { data: { basepath: DB_URL } },
},
$ctx({ DB_URL }),
);
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined();
const dynamicConfig = await makeConfig(
{
app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
config: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL },
}),
},
$ctx({ DB_URL }),
);
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined();
});
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await createApp(c, { env: a } as any, o);
makeApp: async (c, a) => {
return await createApp(c, { env: a } as any);
},
makeHandler: (c, a, o) => {
makeHandler: (c, a) => {
console.log("args", a);
return async (request: any) => {
const app = await createApp(
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
connection: { url: DB_URL },
},
a as any,
o,
);
return app.fetch(request);
};
@@ -3,9 +3,9 @@
import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import type { MaybePromise } from "bknd";
import type { App, MaybePromise } from "bknd";
import { $console } from "bknd/utils";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global {
@@ -34,33 +34,33 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
};
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {},
opts: RuntimeOptions = {
// by default, require the app to be rebuilt every time
force: true,
},
) {
const appConfig = await makeConfig(
const appConfig = await makeConfig(config, ctx);
return await createRuntimeApp<Env>(
{
...config,
...appConfig,
onBuilt: async (app) => {
if (ctx.ctx) {
registerAsyncsExecutionContext(app, ctx?.ctx);
}
await config.onBuilt?.(app);
await appConfig.onBuilt?.(app);
},
},
ctx,
ctx?.env,
);
return await createRuntimeApp<Env>(appConfig, ctx?.env, opts);
}
// compatiblity
export const getFresh = createApp;
let app: App | undefined;
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
serveOptions?: (args: Env) => {
warm?: boolean;
},
) {
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
@@ -96,8 +96,11 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
}
}
const context = { request, env, ctx } as CloudflareContext<Env>;
const app = await createApp(config, context);
const { warm } = serveOptions?.(env) ?? {};
if (!app || warm !== true) {
const context = { request, env, ctx } as CloudflareContext<Env>;
app = await createApp(config, context);
}
return app.fetch(request, env, ctx);
},
@@ -1,11 +1,12 @@
/// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const script = `
import { DurableObject } from "cloudflare:workers";
@@ -40,6 +41,9 @@ export default {
}
`;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {
@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("cacheWorkersKV", async () => {
beforeAll(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants, type CloudflareContext } from "./config";
export { constants, makeConfig, type CloudflareContext } from "./config";
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
export { devFsVitePlugin, devFsWrite } from "./vite";
+54 -26
View File
@@ -5,8 +5,9 @@ import {
type CloudflareBkndConfig,
type CloudflareEnv,
} from "bknd/adapter/cloudflare";
import type { PlatformProxy } from "wrangler";
import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
import process from "node:process";
import { $console } from "bknd/utils";
export type WithPlatformProxyOptions = {
/**
@@ -14,22 +15,49 @@ export type WithPlatformProxyOptions = {
* You can override/force this by setting this option.
*/
useProxy?: boolean;
proxyOptions?: GetPlatformProxyOptions;
};
async function getPlatformProxy(opts?: GetPlatformProxyOptions) {
try {
const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then(
(pkg) => pkg.default,
);
$console.log("Using wrangler version", version);
const { getPlatformProxy } = await import("wrangler");
return getPlatformProxy(opts);
} catch (e) {
$console.error("Failed to import wrangler", String(e));
const resolved = import.meta.resolve("wrangler");
$console.log("Wrangler resolved to", resolved);
const file = resolved?.split("/").pop();
if (file?.endsWith(".json")) {
$console.error(
"You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml",
);
}
}
process.exit(1);
}
export function withPlatformProxy<Env extends CloudflareEnv>(
config?: CloudflareBkndConfig<Env>,
config: CloudflareBkndConfig<Env> = {},
opts?: WithPlatformProxyOptions,
) {
const use_proxy =
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
let proxy: PlatformProxy | undefined;
$console.log("Using cloudflare platform proxy");
async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) {
if (!proxy) {
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
proxy = await getPlatformProxy();
setTimeout(proxy?.dispose, 1000);
proxy = await getPlatformProxy(opts?.proxyOptions);
process.on("exit", () => {
proxy?.dispose();
});
}
return proxy.env as unknown as Env;
}
@@ -37,31 +65,31 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
}
return {
...config,
beforeBuild: async (app, registries) => {
if (!use_proxy) return;
const env = await getEnv();
registerMedia(env, registries as any);
await config?.beforeBuild?.(app, registries);
},
bindings: async (env) => {
return (await config?.bindings?.(await getEnv(env))) || {};
},
// @ts-ignore
app: async (_env) => {
const env = await getEnv(_env);
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
const appConfig = typeof config.app === "function" ? await config.app(env) : config;
const connection =
use_proxy && binding
? d1Sqlite({
binding: binding.value as any,
})
: appConfig.connection;
if (config?.app === undefined && use_proxy) {
const binding = getBinding(env, "D1Database");
return {
connection: d1Sqlite({
binding: binding.value,
}),
};
} else if (typeof config?.app === "function") {
return config?.app(env);
}
return config?.app || {};
return {
...appConfig,
beforeBuild: async (app, registries) => {
if (!use_proxy) return;
const env = await getEnv();
registerMedia(env, registries as any);
await config?.beforeBuild?.(app, registries);
},
bindings: async (env) => {
return (await config?.bindings?.(await getEnv(env))) || {};
},
connection,
};
},
} satisfies CloudflareBkndConfig<Env>;
}
@@ -49,6 +49,8 @@ export function registerMedia(
* @todo: add tests (bun tests won't work, need node native tests)
*/
export class StorageR2Adapter extends StorageAdapter {
public keyPrefix: string = "";
constructor(private readonly bucket: R2Bucket) {
super();
}
@@ -175,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter {
}
protected getKey(key: string) {
if (this.keyPrefix.length > 0) {
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
}
return key;
}
@@ -3,8 +3,12 @@ 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 { describe, afterAll, test, expect, beforeAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => {
@@ -24,7 +28,8 @@ describe("StorageR2Adapter", async () => {
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(viTestRunner, adapter, file);
// miniflare doesn't support range requests
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
});
afterAll(async () => {
+172 -35
View File
@@ -24,45 +24,157 @@ export function devFsVitePlugin({
projectRoot = config.root;
},
configureServer(server) {
if (!isDev) return;
if (!isDev) {
verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping");
return;
}
// Track active chunked requests
const activeRequests = new Map<
string,
{
totalChunks: number;
filename: string;
chunks: string[];
receivedChunks: number;
}
>();
// Intercept stdout to watch for our write requests
const originalStdoutWrite = process.stdout.write;
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
const output = chunk.toString();
// Check if this output contains our special write request
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) {
try {
// Extract the JSON from the log line
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/);
if (match) {
const writeRequest = JSON.parse(match[1]);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
if (verbose) {
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
}
// Skip our own debug output
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
// @ts-ignore
// biome-ignore lint/style/noArguments: <explanation>
return originalStdoutWrite.apply(process.stdout, arguments);
}
// Process the write request immediately
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
await nodeWriteFile(fullPath, writeRequest.data);
if (verbose) {
console.debug("[dev-fs-plugin] File written successfully!");
}
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Track if we process any protocol messages (to suppress output)
let processedProtocolMessage = false;
// Don't output the raw write request to console
return true;
// Process all start markers in this output
if (output.includes("{{DEV_FS_START}}")) {
const startMatches = [
...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g),
];
for (const startMatch of startMatches) {
const requestId = startMatch[1];
const totalChunks = Number.parseInt(startMatch[2]);
const filename = startMatch[3];
activeRequests.set(requestId, {
totalChunks,
filename,
chunks: new Array(totalChunks),
receivedChunks: 0,
});
verbose &&
console.debug(
`[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`,
);
}
processedProtocolMessage = true;
}
// Process all chunk data in this output
if (output.includes("{{DEV_FS_CHUNK}}")) {
const chunkMatches = [
...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g),
];
for (const chunkMatch of chunkMatches) {
const requestId = chunkMatch[1];
const chunkIndex = Number.parseInt(chunkMatch[2]);
const chunkData = chunkMatch[3];
const request = activeRequests.get(requestId);
if (request) {
request.chunks[chunkIndex] = chunkData;
request.receivedChunks++;
verbose &&
console.debug(
`[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`,
);
// Validate base64 chunk
if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) {
verbose &&
console.warn(
`[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`,
);
}
}
} catch (error) {
// Not a valid write request, continue with normal output
}
processedProtocolMessage = true;
}
// Process all end markers in this output
if (output.includes("{{DEV_FS_END}}")) {
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
for (const endMatch of endMatches) {
const requestId = endMatch[1];
const request = activeRequests.get(requestId);
if (request && request.receivedChunks === request.totalChunks) {
try {
// Reconstruct the base64 string
const fullBase64 = request.chunks.join("");
verbose &&
console.debug(
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
);
// Decode and parse
const decodedJson = atob(fullBase64);
const writeRequest = JSON.parse(decodedJson);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
verbose &&
console.debug(
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
);
// Process the write request
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
verbose &&
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
await nodeWriteFile(fullPath, writeRequest.data);
verbose &&
console.debug("[dev-fs-plugin] File written successfully!");
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Clean up
activeRequests.delete(requestId);
return true;
}
} catch (error) {
console.error(
"[dev-fs-plugin] Error processing chunked request:",
String(error),
);
activeRequests.delete(requestId);
}
} else if (request) {
verbose &&
console.debug(
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
);
}
}
processedProtocolMessage = true;
}
// If we processed any protocol messages, suppress output
if (processedProtocolMessage) {
return callback ? callback() : true;
}
// @ts-ignore
@@ -78,7 +190,10 @@ export function devFsVitePlugin({
// @ts-ignore
transform(code, id, options) {
// Only transform in SSR mode during development
if (!isDev || !options?.ssr) return;
//if (!isDev || !options?.ssr) return;
if (!isDev) {
return;
}
// Check if this is the bknd config file
if (id.includes(configFile)) {
@@ -92,7 +207,7 @@ export function devFsVitePlugin({
if (typeof globalThis !== 'undefined') {
globalThis.__devFsPolyfill = {
writeFile: async (filename, data) => {
${verbose ? "console.debug('dev-fs polyfill: Intercepting write request for', filename);" : ""}
${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""}
// Use console logging as a communication channel
// The main process will watch for this specific log pattern
@@ -103,16 +218,38 @@ if (typeof globalThis !== 'undefined') {
timestamp: Date.now()
};
// Output as a specially formatted console message
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest));
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""}
// Output as a specially formatted console message with end delimiter
// Base64 encode the JSON to avoid any control character issues
const jsonString = JSON.stringify(writeRequest);
const encodedJson = btoa(jsonString);
// Split into reasonable chunks that balance performance vs reliability
const chunkSize = 2000; // 2KB chunks - safe for most environments
const chunks = [];
for (let i = 0; i < encodedJson.length; i += chunkSize) {
chunks.push(encodedJson.slice(i, i + chunkSize));
}
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
// Send start marker (use stdout.write to avoid console display)
process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n');
// Send each chunk
chunks.forEach((chunk, index) => {
process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n');
});
// Send end marker
process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n');
return Promise.resolve();
}
};
}
`;
}`;
return polyfill + code;
} else {
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
}
},
} satisfies Plugin;
+55 -52
View File
@@ -6,28 +6,27 @@ import {
guessMimeType,
type MaybePromise,
registries as $registries,
type Merge,
} from "bknd";
import { $console } from "bknd/utils";
import type { Context, MiddlewareHandler, Next } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>);
onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
};
export type BkndConfig<Args = any, Additional = {}> = Merge<
CreateAppConfig &
Omit<Additional, "app"> & {
app?:
| Omit<BkndConfig<Args, Additional>, "app">
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0];
}
>;
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type CreateAdapterAppOptions = {
force?: boolean;
id?: string;
};
export type FrameworkOptions = CreateAdapterAppOptions;
export type RuntimeOptions = CreateAdapterAppOptions;
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
@@ -41,7 +40,7 @@ export type DefaultArgs = {
export async function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>,
args?: Args,
): Promise<CreateAppConfig> {
): Promise<Omit<BkndConfig<Args>, "app">> {
let additionalConfig: CreateAppConfig = {};
const { app, ...rest } = config;
if (app) {
@@ -58,59 +57,55 @@ export async function makeConfig<Args = DefaultArgs>(
return { ...rest, ...additionalConfig };
}
// a map that contains all apps by id
const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config,
args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> {
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
const appConfig = await 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 = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
): Promise<{ app: App; config: BkndConfig<Args> }> {
await config.beforeBuild?.(undefined, $registries);
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
if (connection) {
$console.warn(
"Connection is not a valid connection object, using default SQLite connection",
);
}
appConfig.connection = connection;
}
app = App.create(appConfig);
if (!opts?.force) {
apps.set(id, app);
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: "file:data.db" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
return app;
return {
app: App.create(appConfig),
config: appConfig,
};
}
export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {},
args?: Args,
opts?: FrameworkOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const { app, config: appConfig } = await createAdapterApp(config, args);
if (!app.isBuilt()) {
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await config.onBuilt?.(app);
await appConfig.onBuilt?.(app);
},
"sync",
);
}
await config.beforeBuild?.(app, $registries);
await appConfig.beforeBuild?.(app, $registries);
await app.build(config.buildConfig);
}
@@ -120,9 +115,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args,
opts?: RuntimeOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const { app, config: appConfig } = await createAdapterApp(config, args);
if (!app.isBuilt()) {
app.emgr.onEvent(
@@ -135,7 +129,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
await appConfig.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
@@ -143,7 +137,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
"sync",
);
await config.beforeBuild?.(app, $registries);
await appConfig.beforeBuild?.(app, $registries);
await app.build(config.buildConfig);
}
@@ -166,23 +160,32 @@ export async function createRuntimeApp<Args = DefaultArgs>(
* });
* ```
*/
export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
export function serveStaticViaImport(opts?: {
manifest?: Manifest;
appendRaw?: boolean;
package?: string;
}) {
let files: string[] | undefined;
const pkg = opts?.package ?? "bknd";
// @ts-ignore
return async (c: Context, next: Next) => {
if (!files) {
const manifest =
opts?.manifest ||
((await import("bknd/dist/manifest.json", { with: { type: "json" } }))
.default as Manifest);
((
await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, {
with: { type: "json" },
})
).default as Manifest);
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
}
const path = c.req.path.substring(1);
if (files.includes(path)) {
try {
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`;
const content = await import(/* @vite-ignore */ url, {
with: { type: "text" },
}).then((m) => m.default);
@@ -195,7 +198,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
});
}
} catch (e) {
console.error("Error serving static file:", e);
console.error(`Error serving static file "${path}":`, String(e));
return c.text("File not found", 404);
}
}
+9 -8
View File
@@ -1,5 +1,6 @@
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils";
// @ts-expect-error next is not installed
import type { NextApiRequest } from "next";
type NextjsEnv = NextApiRequest["env"];
@@ -9,17 +10,18 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
return await createFrameworkApp(config, args);
}
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
if (!cleanRequest) return req;
const url = new URL(req.url);
cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
cleanRequest?.searchParams?.forEach((k) => {
url.searchParams.delete(k);
});
if (isNode()) {
return new Request(url.toString(), {
@@ -40,11 +42,10 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = process.env as Env,
) {
return async (req: Request) => {
const app = await getApp(config, args, opts);
const app = await getApp(config, args);
const request = getCleanRequest(req, cleanRequest);
return app.fetch(request);
};
@@ -1,19 +1,29 @@
import { genericSqlite } from "bknd";
import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { DatabaseSync } from "node:sqlite";
import { omitKeys } from "bknd/utils";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
export type NodeSqliteConnectionConfig = Omit<
GenericSqliteConnectionConfig<DatabaseSync>,
"name" | "supports"
> &
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
let db: DatabaseSync;
export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
let db: DatabaseSync | undefined;
if (config) {
if ("database" in config) {
if ("database" in config && config.database) {
db = config.database;
} else {
} else if (config.url) {
db = new DatabaseSync(config.url);
}
} else {
}
if (!db) {
db = new DatabaseSync(":memory:");
}
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
"node-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
};
},
{
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
supports: {
batching: false,
},
@@ -1,8 +1,13 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe } from "vitest";
import { describe, beforeAll, afterAll, test, expect, vi } from "vitest";
import { viTestRunner } from "../vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, {
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
}),
rawDialectDetails: [],
});
test("onCreateConnection", async () => {
const called = vi.fn(() => null);
const conn = nodeSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(DatabaseSync);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(DatabaseSync);
expect(called).toHaveBeenCalledOnce();
});
});
+10
View File
@@ -1,3 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
export * from "./node.adapter";
export * from "./storage";
export * from "./connection/NodeSqliteConnection";
export async function writer(path: string, content: string) {
await writeFile(path, content);
}
export async function reader(path: string) {
return await readFile(path, "utf-8");
}
+8 -13
View File
@@ -2,7 +2,7 @@ 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/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config, type App } from "bknd";
import { $console } from "bknd/utils";
@@ -17,15 +17,14 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = process.env as Env,
) {
const root = path.relative(
process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
);
if (relativeDistPath) {
console.warn("relativeDistPath is deprecated, please use distPath instead");
$console.warn("relativeDistPath is deprecated, please use distPath instead");
}
registerLocalMediaAdapter();
@@ -34,21 +33,18 @@ export async function createApp<Env = NodeEnv>(
serveStatic: serveStatic({ root }),
...config,
},
// @ts-ignore
args ?? { env: process.env },
opts,
args,
);
}
export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = process.env as Env,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args);
}
return app.fetch(req);
};
@@ -56,14 +52,13 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = process.env as Env,
) {
honoServe(
{
port,
hostname,
fetch: createHandler(config, args, opts),
fetch: createHandler(config, args),
},
(connInfo) => {
$console.log(`Server is running on http://localhost:${connInfo.port}`);
+1 -5
View File
@@ -1,11 +1,7 @@
import { describe, beforeAll, afterAll } from "vitest";
import { describe } from "vitest";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(viTestRunner, {
@@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter {
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// Parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// Validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const content = await readFile(`${this.config.path}/${key}`);
const filePath = `${this.config.path}/${key}`;
const stats = await stat(filePath);
const fileSize = stats.size;
const mimeType = guessMimeType(key);
return new Response(content, {
status: 200,
headers: {
"Content-Type": mimeType || "application/octet-stream",
"Content-Length": content.length.toString(),
},
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// Invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const content = await readFile(filePath, { encoding: null });
const chunk = content.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.length.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// Normal request - return entire file
const content = await readFile(filePath);
responseHeaders.set("Content-Length", content.length.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch (error) {
// Handle file reading errors
return new Response("", { status: 404 });
@@ -1,9 +1,13 @@
import { describe } from "vitest";
import { describe, beforeAll, afterAll } 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";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
+3 -2
View File
@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict";
import { test, describe, beforeEach, afterEach } from "node:test";
import { test, describe, beforeEach, afterEach, after, before } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
}),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: () => {},
afterAll: after,
beforeAll: before,
};
+2 -1
View File
@@ -1,5 +1,5 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: afterAll,
beforeAll: beforeAll,
};

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