mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
409 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a90b9356b | |||
| b070fb591e | |||
| 6489d08434 | |||
| 81bf41ce34 | |||
| d9ab583a16 | |||
| e68df5e606 | |||
| 6304ef81f6 | |||
| 6c7e82781d | |||
| a8e5c45a22 | |||
| ee28c2ef0c | |||
| 9219bf3b3c | |||
| e437ce7120 | |||
| 2417833ed7 | |||
| ab1eddcb6a | |||
| 237b42bb9c | |||
| d206c38d87 | |||
| d160af5cec | |||
| d03cd7f7bc | |||
| 4afba215b9 | |||
| dc8aca6a97 | |||
| 4121597fa2 | |||
| 9628720f87 | |||
| ff216ec4e5 | |||
| 06f9c3ee15 | |||
| 7751ee5db8 | |||
| feb3911d46 | |||
| ab545d3ca2 | |||
| 06c3a6f89b | |||
| 898a825f6e | |||
| a5ddf6aa93 | |||
| 5aa286298c | |||
| 55cb2bacef | |||
| e6ea3f64d7 | |||
| a5f5ea2084 | |||
| 6fb8e9a0e7 | |||
| abc6de6ad3 | |||
| 71b9a0f546 | |||
| 038ec0d672 | |||
| 7832328a50 | |||
| e5bfc602e1 | |||
| 0318e595f2 | |||
| 80b0716a4a | |||
| 3894271b17 | |||
| 8bf8b22e57 | |||
| 367d5878df | |||
| fb429e57e2 | |||
| 9008f9c6a3 | |||
| 6321a9935a | |||
| eb22289460 | |||
| c3198394de | |||
| 51237f5e1a | |||
| 01483b912f | |||
| 6288faef33 | |||
| 384e337bbd | |||
| cc1e1976f7 | |||
| 224d98879a | |||
| d1522c97ea | |||
| 8805f017a1 | |||
| 0ef06375c0 | |||
| 3d5c2cd3a8 | |||
| fc60dfd042 | |||
| c8ee30b3da | |||
| b85c3a8a9b | |||
| e20c2ef92c | |||
| 5873baad3c | |||
| aca162f9c8 | |||
| 9f0997c28a | |||
| 1c114d9533 | |||
| 68063986d1 | |||
| 53f8609e9d | |||
| 833bfa5049 | |||
| defcdfef0b | |||
| 348ac7f8b0 | |||
| 16b1cde8ea | |||
| 436b195242 | |||
| ccd504440e | |||
| 023f5391e4 | |||
| 11e3488917 | |||
| 9685c5d4e1 | |||
| 9b008be512 | |||
| 5352331464 | |||
| d9bce049ef | |||
| 0e4f25cf11 | |||
| 4a5bdade8d | |||
| 047bb80a97 | |||
| d826cb23a3 | |||
| 23a9e7c278 | |||
| a9fe43130e | |||
| 3b7ec06703 | |||
| 55a124b519 | |||
| dc3fe330d7 | |||
| d541167fb3 | |||
| 7e8b357bbb | |||
| 521d8bc6ff | |||
| 19f1941544 | |||
| f9373bd0e6 | |||
| 5384738b67 | |||
| d82e28e6b1 | |||
| c38bc95b1d | |||
| 6651fce099 | |||
| 91a1fc8a0c | |||
| d00e5fe68c | |||
| 6faf0d421f | |||
| 19e51fae63 | |||
| 7e01cc6101 | |||
| 103cbd11df | |||
| 03d3b8e407 | |||
| a97a79f11e | |||
| 90b4de7093 | |||
| 81f3914e7f | |||
| 5163e2142e | |||
| 15ed075de3 | |||
| 09b9a5e857 | |||
| 4f7925dfd8 | |||
| 21e970cd8c | |||
| 40b70e7a20 | |||
| 9958dd7308 | |||
| fef77eea61 | |||
| ed41887d74 | |||
| 8ba4793160 | |||
| 65e17db2a2 | |||
| e21b5e5b55 | |||
| 959dee013e | |||
| a39e76a440 | |||
| acc10377ca | |||
| 506c7d84cc | |||
| 7ba542c040 | |||
| b784d8611a | |||
| a21d4ad6a7 | |||
| 731b7a7e8d | |||
| e56fc9c368 | |||
| d1aa2da5b1 | |||
| 52fe9aa085 | |||
| 065821d9a5 | |||
| 0fc817382a | |||
| 2e6b95e484 | |||
| 4e31f48917 | |||
| b2872eb196 | |||
| 3d804f5ac5 | |||
| da025efee1 | |||
| ab1fa4c895 | |||
| ebe3bc2ff5 | |||
| f792d8b93e | |||
| 319469f44b | |||
| 1359741e5f | |||
| 8f4de33a76 | |||
| 43dbc856ce | |||
| 5a8f2b4894 | |||
| 2627213de7 | |||
| 36e1bb1867 | |||
| 0cf1d86213 | |||
| 3fac740771 | |||
| 5e5dc62304 | |||
| c1d6384271 | |||
| f2bd04770a | |||
| 41d43126cb | |||
| a39407c9a3 | |||
| 5d6046b149 | |||
| 000f8d20a8 | |||
| 9e7547e787 | |||
| c3ae4a3999 | |||
| 5c3eeb7642 | |||
| a2fa11ccd0 | |||
| fdee39da62 | |||
| 16a3a67634 | |||
| 2b5e1771de | |||
| a16e017e39 | |||
| 079d613b2e | |||
| ff56d616d9 | |||
| 5b3d36c527 | |||
| 2885fea077 | |||
| 5ce22162c3 | |||
| 3f55c0f2c5 | |||
| 21f642161d | |||
| 7912729159 | |||
| d3e87be604 | |||
| ba3d11edab | |||
| 9ec31373e1 | |||
| 70b25a9f9b | |||
| b6717f0237 | |||
| c57f3e8070 | |||
| c2f4f92d1a | |||
| 6eb8525656 | |||
| e2ebb57564 | |||
| 2b7bbc5df0 | |||
| 8766b742c6 | |||
| 3befa43a9e | |||
| a842808464 | |||
| bd1ef8ed57 | |||
| a0019e5500 | |||
| 8ec67a5492 | |||
| 960f7c3fb2 | |||
| 793c214e6d | |||
| 4094004b83 | |||
| 300d86ff7c | |||
| 4c9b662f6f | |||
| a862cfdcf1 | |||
| de62f4e729 | |||
| 7e399830e5 | |||
| ee2ab982df | |||
| 5be55a6fa6 | |||
| 341eb13425 | |||
| 68fbb6e933 | |||
| 8b36985252 | |||
| ff86240b0e | |||
| 40bbdb904f | |||
| a333d537b0 | |||
| 108c108d82 | |||
| 4575d89cfe | |||
| 0ca0828581 | |||
| 07a57ebf67 | |||
| e9f1241ec3 | |||
| 80903d4ffa | |||
| c8290802e9 | |||
| ac6cd4a900 | |||
| a91ddb1ec3 | |||
| ed47c5bf51 | |||
| dbb19a27f4 | |||
| 71a17696eb | |||
| 2dbafebcea | |||
| 84d4635ec3 | |||
| e9c92b6086 | |||
| 5fa7601ad5 | |||
| 2c7054c317 | |||
| 5417aa174e | |||
| 6093f4f46f | |||
| 2178e0ee8b | |||
| be39e8a391 | |||
| b57f362e3a | |||
| e055e477ae | |||
| 422f7893b5 | |||
| 0a50f9850c | |||
| 42f340b189 | |||
| ef41b71921 | |||
| 2847e64b77 | |||
| 28390b0b84 | |||
| 0b58cadbd0 | |||
| 574b37abcd | |||
| 0dbf71e6b5 | |||
| fd1f0f7c54 | |||
| ebad3d15ec | |||
| 2fd5e71574 | |||
| b787837dd2 | |||
| 1fc6e810ae | |||
| 88cc406002 | |||
| 511f639d7a | |||
| 0df17221df | |||
| 347fe0f6ce | |||
| f3c6cd7620 | |||
| 649c465bd0 | |||
| 67604b344a | |||
| aedae8e757 | |||
| 166409fdf4 | |||
| f2aad9caac | |||
| 292e4595ea | |||
| 869031bbfa | |||
| 88e5c06e9d | |||
| cfb4b0e336 | |||
| 2d56b54e0c | |||
| 5d4a77fb10 | |||
| eb0822bbff | |||
| 38902ebcba | |||
| 22e43c2523 | |||
| e68e5792be | |||
| 511c6539fb | |||
| 9070f96571 | |||
| 0347efa592 | |||
| 1b8ce41837 | |||
| 6624927286 | |||
| 803f42a72b | |||
| 7e5c28d621 | |||
| 2f88c2216c | |||
| b89c090f87 | |||
| f4a7cde487 | |||
| a2d83d01a4 | |||
| 66392d094d | |||
| 0352c72fb6 | |||
| 3f9be3a418 | |||
| fd3dd310a5 | |||
| e6ff5c3f0b | |||
| 5377ac1a41 | |||
| 0e870cda81 | |||
| b784e1c1c4 | |||
| 90f93caff4 | |||
| d6dcfe3acc | |||
| b974fe7ec7 | |||
| 6483ff74bb | |||
| 27e2064f0c | |||
| 5cf91bb35a | |||
| 81533d855a | |||
| db58911df3 | |||
| 2540c83382 | |||
| 569d021316 | |||
| ba9713587c | |||
| eecaeb7c06 | |||
| 610e263477 | |||
| 0f54e8267f | |||
| 1fdee8435d | |||
| 1eeb23232a | |||
| 6102759da8 | |||
| 6cea581e42 | |||
| 5e553a7fce | |||
| 5e71fc8947 | |||
| d1ba638cd5 | |||
| d31416f85d | |||
| 55082e9d0e | |||
| 0d74625270 | |||
| 800f14ede2 | |||
| 560379bd89 | |||
| daafee2c06 | |||
| ab3e8ce55f | |||
| a655c990ed | |||
| 2c976adb77 | |||
| 1128ac500d | |||
| 06d7558c3c | |||
| 7344b1cf3d | |||
| ace9c1b2b9 | |||
| 0629e1bc50 | |||
| 832eb6ac31 | |||
| aa8bf156b0 | |||
| 83c1c86eff | |||
| ffe53d3fb5 | |||
| 49aee37199 | |||
| 54eee8cd34 | |||
| 5e62e681e7 | |||
| 99c1645411 | |||
| 564eab23af | |||
| f2da54c92b | |||
| cd262097dc | |||
| d1726b23f1 | |||
| 50e9be833b | |||
| 806b7427c1 | |||
| 8742938074 | |||
| 36e61cab3f | |||
| 17d4adbbfa | |||
| d052871fe0 | |||
| b0e5a49b0b | |||
| 35cbcc221a | |||
| 26d1f2b583 | |||
| 95d114ea68 | |||
| 059becbf09 | |||
| 91120091a3 | |||
| 99a812cc75 | |||
| 29a2de31c6 | |||
| 2688bf261b | |||
| 15c32c0e6d | |||
| d6f2500be2 | |||
| fa8931ad15 | |||
| 6e3060141b | |||
| 07029e3797 | |||
| 3672cc7f2c | |||
| ac32eb128e | |||
| ffdf453fea | |||
| 9dd7432e6b | |||
| 8c4a8d91a2 | |||
| 9aae6e78d6 | |||
| 317b2b50ad | |||
| a0b2dde034 | |||
| 70eaa22327 | |||
| ddfc3e599f | |||
| c11dd2bd9e | |||
| 77c85bfd5c | |||
| 7f8d8d26ac | |||
| 655bb241a8 | |||
| 1ef5bf7d15 | |||
| 9a1589da0b | |||
| f231353228 | |||
| d2de642c17 | |||
| 50c5383138 | |||
| fff88a9237 | |||
| d768a80526 | |||
| 0d79b19c66 | |||
| fea2812688 | |||
| 62368c691a | |||
| dd9617598f | |||
| a7f6d45ca9 | |||
| 92656523ff | |||
| e6ef7d9ff4 | |||
| 732cd31e1f | |||
| 1a7670f57a | |||
| af573cc79a | |||
| 1df87c8a16 | |||
| 3c5e3f9638 | |||
| 7a0a7481c0 | |||
| bdcc81b2f1 | |||
| 210b22e307 | |||
| 94e168589d | |||
| c22339e4bf | |||
| d01058595f | |||
| fdec5f0693 | |||
| e8f2c70279 | |||
| 2c5371610b | |||
| 758a89b5d7 | |||
| e3888537f9 | |||
| a559a2eabc | |||
| ac891a4b61 | |||
| 412eab6653 | |||
| 2fb07698d5 | |||
| 64e400c4f1 | |||
| 86d6e891a8 | |||
| 7b86611f35 | |||
| 9e316a92f6 | |||
| 12ef72d595 | |||
| 75c29ae5e2 | |||
| f0551f50d1 | |||
| 03aac3437b | |||
| f07f3d1007 | |||
| c833b2b539 | |||
| 6070394cca |
+1
-4
@@ -1,10 +1,7 @@
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"bknd": {
|
"bknd": {
|
||||||
"url": "http://localhost:3000/mcp",
|
"url": "http://localhost:28623/api/system/mcp"
|
||||||
"headers": {
|
|
||||||
"API_KEY": "value"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -20,11 +35,11 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: "1.2.19"
|
bun-version: "1.3.3"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: bun install
|
run: bun install #--linker=hoisted
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@ packages/media/.env
|
|||||||
.npmrc
|
.npmrc
|
||||||
/.verdaccio
|
/.verdaccio
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
.git_old
|
.git_old
|
||||||
docker/tmp
|
docker/tmp
|
||||||
.debug
|
.debug
|
||||||
|
|||||||
Vendored
+19
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
# Contributing to bknd
|
||||||
|
|
||||||
|
Thank you for your interest in contributing to bknd. This guide will help you get started, understand the codebase, and submit contributions that align with the project's direction.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Before You Start](#before-you-start)
|
||||||
|
- [Important Resources](#important-resources)
|
||||||
|
- [Development Setup](#development-setup)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Understanding the Codebase](#understanding-the-codebase)
|
||||||
|
- [Running Tests](#running-tests)
|
||||||
|
- [Code Style](#code-style)
|
||||||
|
- [Submitting Changes](#submitting-changes)
|
||||||
|
- [AI-Generated Code](#ai-generated-code)
|
||||||
|
- [Contributing to Documentation](#contributing-to-documentation)
|
||||||
|
- [Reporting Bugs](#reporting-bugs)
|
||||||
|
- [Getting Help](#getting-help)
|
||||||
|
- [Contributors](#contributors)
|
||||||
|
- [Maintainers](#maintainers)
|
||||||
|
|
||||||
|
## Before You Start
|
||||||
|
|
||||||
|
**Open a GitHub Issue before writing code.** This is the preferred way to propose any change. It lets maintainers and the community align on the approach before time is spent on implementation. If you have discussed a contribution in the [Discord server](https://discord.com/invite/952SFk8Tb8) instead, include a link to the relevant Discord thread in your pull request. Pull requests submitted without a corresponding issue or Discord discussion may be closed.
|
||||||
|
|
||||||
|
**Unsolicited architectural changes will be closed.** The internal architecture of bknd is intentional. Refactors, restructuring, or changes to core patterns must be discussed and approved before any code is written.
|
||||||
|
|
||||||
|
Contributions that are generally welcome (after opening an issue):
|
||||||
|
|
||||||
|
- Bug fixes
|
||||||
|
- New adapters, strategies, or storage backends
|
||||||
|
- Documentation improvements
|
||||||
|
- New examples
|
||||||
|
- Test coverage improvements
|
||||||
|
|
||||||
|
**A note on versioning**: bknd is pre-1.0 and under active development. Full backward compatibility is not guaranteed before v1.0.0. Contributors should be aware that APIs and internal interfaces may change between releases.
|
||||||
|
|
||||||
|
## Important Resources
|
||||||
|
|
||||||
|
- **Documentation**: https://docs.bknd.io
|
||||||
|
- **Issue Tracker**: https://github.com/bknd-io/bknd/issues
|
||||||
|
- **Discord**: https://discord.com/invite/952SFk8Tb8
|
||||||
|
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22.13 or higher
|
||||||
|
- Bun 1.3.3
|
||||||
|
|
||||||
|
### Getting Started
|
||||||
|
|
||||||
|
1. Fork the repository and clone your fork.
|
||||||
|
|
||||||
|
2. Install dependencies from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Navigate to the main app package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Copy the example environment file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
The default `.env.example` includes a file-based SQLite database (`file:.db/dev.db`). You can change `VITE_DB_URL` to `:memory:` for an in-memory database instead.
|
||||||
|
|
||||||
|
5. Start the dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The dev server runs on `http://localhost:28623` using Vite with Hono.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This is a Bun monorepo using workspaces. The vast majority of the code lives in `app/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
bknd/
|
||||||
|
app/ # Main "bknd" npm package (this is where most work happens)
|
||||||
|
src/
|
||||||
|
adapter/ # Runtime/framework adapters (node, bun, cloudflare, nextjs, astro, etc.)
|
||||||
|
auth/ # Authentication module (strategies, sessions, user pool)
|
||||||
|
cli/ # CLI commands
|
||||||
|
core/ # Shared utilities, event system, drivers, server helpers
|
||||||
|
data/ # Data module (entities, fields, relations, queries, schema)
|
||||||
|
flows/ # Workflow engine and tasks
|
||||||
|
media/ # Media module (storage adapters, uploads)
|
||||||
|
modes/ # Configuration modes (code, hybrid, db)
|
||||||
|
modules/ # Module system, MCP server, permissions framework
|
||||||
|
plugins/ # Built-in plugins (auth, data, cloudflare, dev)
|
||||||
|
ui/ # All frontend code
|
||||||
|
client/ # TypeScript SDK and React hooks (exported as bknd/client)
|
||||||
|
elements/ # React components for auth/media (exported as bknd/elements)
|
||||||
|
(everything else) # Admin UI (exported as bknd/ui)
|
||||||
|
App.ts # Central application orchestrator
|
||||||
|
index.ts # Main package exports
|
||||||
|
__test__/ # Unit tests (mirrors src/ structure)
|
||||||
|
e2e/ # End-to-end tests (Playwright)
|
||||||
|
build.ts # Build script (tsup/esbuild)
|
||||||
|
build.cli.ts # CLI build script
|
||||||
|
packages/ # Small satellite packages
|
||||||
|
cli/ # Standalone CLI package (bknd-cli)
|
||||||
|
plasmic/ # Plasmic integration
|
||||||
|
postgres/ # Postgres helper
|
||||||
|
sqlocal/ # SQLocal helper
|
||||||
|
docs/ # Documentation site (Next.js + Fumadocs, deployed to docs.bknd.io)
|
||||||
|
examples/ # Example projects across runtimes and frameworks
|
||||||
|
docker/ # Docker configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Understanding the Codebase
|
||||||
|
|
||||||
|
### Path Aliases
|
||||||
|
|
||||||
|
The project uses TypeScript path aliases defined in `app/tsconfig.json`. Imports like `core/utils`, `data/connection`, or `auth/authenticate` resolve to directories under `app/src/`. When reading source code, keep this in mind -- an import like `import { Connection } from "data/connection"` refers to `app/src/data/connection`, not an external package.
|
||||||
|
|
||||||
|
### Module System
|
||||||
|
|
||||||
|
bknd is built around four core modules, each with its own schema, API routes, and permissions:
|
||||||
|
|
||||||
|
- **Data** -- entity definitions, field types, relations, queries (backed by Kysely)
|
||||||
|
- **Auth** -- authentication strategies, sessions, user management
|
||||||
|
- **Media** -- file storage with pluggable adapters (S3, Cloudinary, local filesystem, etc.)
|
||||||
|
- **Flows** -- workflow automation and task execution
|
||||||
|
|
||||||
|
These modules are managed by the `ModuleManager` (in `app/src/modules/`), which handles configuration, building, and lifecycle.
|
||||||
|
|
||||||
|
### Adapter Pattern
|
||||||
|
|
||||||
|
Adapters in `app/src/adapter/` allow bknd to run on different runtimes and frameworks. Each adapter provides the glue between bknd's Hono-based server and a specific environment (Node, Bun, Cloudflare Workers, Next.js, Astro, etc.).
|
||||||
|
|
||||||
|
### Plugin System
|
||||||
|
|
||||||
|
Plugins (in `app/src/plugins/`) hook into the app lifecycle via callbacks like `beforeBuild`, `onBuilt`, `onServerInit`, and `schema`. They can add entities, register routes, and extend behavior.
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
The build is handled by a custom `app/build.ts` script using tsup (esbuild under the hood). It builds four targets in parallel:
|
||||||
|
|
||||||
|
- **API** -- the core backend
|
||||||
|
- **UI** -- the admin interface
|
||||||
|
- **Elements** -- standalone React components
|
||||||
|
- **Adapters** -- all runtime/framework adapters
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
All test commands run from the `app/` directory.
|
||||||
|
|
||||||
|
| Command | Runner | What it runs |
|
||||||
|
|---|---|---|
|
||||||
|
| `bun run test` | Bun test | Unit tests (`*.spec.ts` files) |
|
||||||
|
| `bun run test:node` | Vitest | Node-specific tests (`*.vi-test.ts`, `*.vitest.ts`) |
|
||||||
|
| `bun run test:e2e` | Playwright | End-to-end tests (`*.e2e-spec.ts` in `e2e/`) |
|
||||||
|
| `bun run types` | TypeScript | Type checking (no emit) |
|
||||||
|
|
||||||
|
CI runs both Bun and Node tests, with a Postgres 17 service for database integration tests.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
The project uses **Biome** for formatting and linting.
|
||||||
|
|
||||||
|
- 3-space indentation for JavaScript/TypeScript
|
||||||
|
- 100-character line width
|
||||||
|
- Spaces (not tabs)
|
||||||
|
|
||||||
|
To format and lint, run from the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bunx biome format --write ./app
|
||||||
|
bunx biome lint --changed --write ./app
|
||||||
|
```
|
||||||
|
|
||||||
|
Run both before submitting a PR. CI will catch style issues, but it is better to fix them locally first.
|
||||||
|
|
||||||
|
## Submitting Changes
|
||||||
|
|
||||||
|
1. Open a GitHub Issue describing your proposed change, or link to a Discord thread where it was discussed. Wait for feedback before writing code.
|
||||||
|
2. Fork the repo and create a branch from `main`.
|
||||||
|
3. Keep changes focused and minimal. One PR per issue.
|
||||||
|
4. Add or update tests if applicable.
|
||||||
|
5. Run the test suite and linter before pushing.
|
||||||
|
6. Open a pull request against `main`. Reference the issue number in the PR description.
|
||||||
|
|
||||||
|
Expect a response from maintainers, but be patient -- this is an actively developed project and review may take some time.
|
||||||
|
|
||||||
|
## AI-Generated Code
|
||||||
|
|
||||||
|
If you use AI tools to write or assist with your code, you must:
|
||||||
|
|
||||||
|
- **Fully review all AI-generated code yourself** before submitting. You are responsible for understanding and standing behind every line in your PR.
|
||||||
|
- **Include the prompts used** in the PR description. This gives maintainers additional context for reviewing the code.
|
||||||
|
|
||||||
|
Pull requests with unreviewed AI-generated code will be closed.
|
||||||
|
|
||||||
|
## Contributing to Documentation
|
||||||
|
|
||||||
|
The documentation site lives in the `docs/` directory and is a **separate Next.js application** built with [Fumadocs](https://fumadocs.dev). It uses **npm** (not Bun) as its package manager.
|
||||||
|
|
||||||
|
### Docs Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd docs
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The docs dev server runs on `http://localhost:3000`.
|
||||||
|
|
||||||
|
### Where Documentation Lives
|
||||||
|
|
||||||
|
Documentation content is written in MDX and located in `docs/content/docs/`. The directory is organized into:
|
||||||
|
|
||||||
|
- `(documentation)/` -- core documentation pages
|
||||||
|
- `guide/` -- guides and tutorials
|
||||||
|
- `api-reference/` -- API reference (partially auto-generated from OpenAPI)
|
||||||
|
|
||||||
|
To add or edit a page, create or modify an `.mdx` file in the appropriate directory. Page metadata is defined via frontmatter at the top of each file. Navigation order is controlled by `meta.json` files in each directory.
|
||||||
|
|
||||||
|
### Building the Docs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates the OpenAPI and MCP reference pages before building the Next.js site. Make sure the build succeeds locally before submitting a docs PR.
|
||||||
|
|
||||||
|
## Reporting Bugs
|
||||||
|
|
||||||
|
Open a GitHub Issue with:
|
||||||
|
|
||||||
|
- A clear title describing the problem.
|
||||||
|
- Steps to reproduce the bug.
|
||||||
|
- Expected behavior vs. actual behavior.
|
||||||
|
- Your environment (runtime, database, adapter, bknd version).
|
||||||
|
- Any relevant error messages or logs.
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- **Discord**: https://discord.com/invite/952SFk8Tb8
|
||||||
|
- **FAQ / Search**: https://www.answeroverflow.com/c/1308395750564302952
|
||||||
|
- **GitHub Issues**: https://github.com/bknd-io/bknd/issues
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thank you to everyone who has contributed to bknd.
|
||||||
|
|
||||||
|
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|
||||||
|
## Maintainers
|
||||||
|
|
||||||
|
- **Dennis** ([@dswbx](https://github.com/dswbx)) -- Creator and maintainer of bknd
|
||||||
|
- **Cameron Pak** ([@cameronapak](https://github.com/cameronapak)) -- Maintainer of bknd docs
|
||||||
+169
-77
@@ -1,110 +1,202 @@
|
|||||||
# Functional Source License, Version 1.1, MIT Future License
|
|
||||||
|
|
||||||
## Abbreviation
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
FSL-1.1-MIT
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
## Notice
|
1. Definitions.
|
||||||
|
|
||||||
Copyright 2025 Dennis Senn
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
## Terms and Conditions
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
### Licensor ("We")
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
The party offering the Software under these Terms and Conditions.
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
### The Software
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
The "Software" is each version of the software that we make available under
|
"Object" form shall mean any form resulting from mechanical
|
||||||
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
transformation or translation of a Source form, including but
|
||||||
Conditions with the Software.
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
### License Grant
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
Subject to your compliance with this License Grant and the Patents,
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
Redistribution and Trademark clauses below, we hereby grant you the right to
|
form, that is based on (or derived from) the Work and for which the
|
||||||
use, copy, modify, create derivative works, publicly perform, publicly display
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
and redistribute the Software for any Permitted Purpose identified below.
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
### Permitted Purpose
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
means making the Software available to others in a commercial product or
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
service that:
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
1. substitutes for the Software;
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
2. substitutes for any other product or service we offer using the Software
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
that exists as of the date we make the Software available; or
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
3. offers the same or substantially similar functionality as the Software.
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
Permitted Purposes specifically include using the Software:
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
1. for your internal use and access;
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
2. for non-commercial education;
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
3. for non-commercial research; and
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
4. in connection with professional services that you provide to a licensee
|
You may add Your own copyright statement to Your modifications and
|
||||||
using the Software in accordance with these Terms and Conditions.
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
### Patents
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
To the extent your use for a Permitted Purpose would necessarily infringe our
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
patents, the license grant above includes a license under our patents. If you
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
make a claim against any party that the Software infringes or contributes to
|
except as required for reasonable and customary use in describing the
|
||||||
the infringement of any patent, then your patent license to the Software ends
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
immediately.
|
|
||||||
|
|
||||||
### Redistribution
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
The Terms and Conditions apply to all copies, modifications and derivatives of
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
the Software.
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
If you redistribute any copies, modifications or derivatives of the Software,
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
you must include a copy of or a link to these Terms and Conditions and not
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
remove any copyright notices provided in or with the Software.
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
### Disclaimer
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
||||||
|
|
||||||
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
To apply the Apache License to your work, attach the following
|
||||||
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
### Trademarks
|
Copyright 2026 Dennis Senn
|
||||||
|
|
||||||
Except for displaying the License Details and identifying us as the origin of
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
the Software, you have no right under these Terms and Conditions to use our
|
you may not use this file except in compliance with the License.
|
||||||
trademarks, trade names, service marks or product names.
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
## Grant of Future License
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
We hereby irrevocably grant you an additional license to use the Software under
|
Unless required by applicable law or agreed to in writing, software
|
||||||
the MIT license that is effective on the second anniversary of the date we make
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
the Software available. On or after that date, you may use the Software under
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
the MIT license, in which case the following will apply:
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
||||||
of the Software, and to permit persons to whom the Software is furnished to do
|
|
||||||
so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -8,22 +8,38 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</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.
|
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
|
||||||
* **Databases**:
|
* **Databases**:
|
||||||
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
||||||
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
||||||
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
* **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.**
|
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> This project requires Node.js 22 or higher (because of `node:sqlite`).
|
> This project requires Node.js 22.13 or higher (because of `node:sqlite`).
|
||||||
>
|
>
|
||||||
> Please keep in mind that **bknd** is still under active development
|
> Please keep in mind that **bknd** is still under active development
|
||||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
> 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
|
## Size
|
||||||

|

|
||||||

|

|
||||||
@@ -46,13 +62,13 @@ Creating digital products always requires developing both the backend (the logic
|
|||||||
* **Media**: Effortlessly manage and serve all your media files.
|
* **Media**: Effortlessly manage and serve all your media files.
|
||||||
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
|
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
|
||||||
* 🌐 Built on Web Standards for maximum compatibility
|
* 🌐 Built on Web Standards for maximum compatibility
|
||||||
|
* 🛠️ MCP server, client and UI built-in to control your backend
|
||||||
* 🏃♂️ Multiple run modes
|
* 🏃♂️ Multiple run modes
|
||||||
* standalone using the CLI
|
* standalone using the CLI
|
||||||
* using a JavaScript runtime (Node, Bun, workerd)
|
* using a JavaScript runtime (Node, Bun, workerd)
|
||||||
* using a React framework (Next.js, React Router, Astro)
|
* using a React framework (Next.js, React Router, Astro)
|
||||||
* 📦 Official API and React SDK with type-safety
|
* 📦 Official API and React SDK with type-safety
|
||||||
* ⚛️ React elements for auto-configured authentication and media components
|
* ⚛️ React elements for auto-configured authentication and media components
|
||||||
* 🛠️ MCP server, client and UI built-in to control your backend
|
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
The package is mainly split into 4 parts, each serving a specific purpose:
|
The package is mainly split into 4 parts, each serving a specific purpose:
|
||||||
@@ -149,3 +165,19 @@ npx bknd run
|
|||||||
```bash
|
```bash
|
||||||
npm install bknd
|
npm install bknd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) before getting started. It covers dev setup, project structure, testing, code style, and how to submit changes.
|
||||||
|
|
||||||
|
Before writing code, please open a GitHub Issue or start a conversation in [Discord](https://discord.com/invite/952SFk8Tb8) so we can align on the approach.
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thank you to everyone who has contributed to bknd.
|
||||||
|
|
||||||
|
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ VITE_SHOW_ROUTES=
|
|||||||
|
|
||||||
# ===== Test Credentials =====
|
# ===== Test Credentials =====
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
PLUNK_API_KEY=
|
||||||
R2_TOKEN=
|
R2_TOKEN=
|
||||||
|
|
||||||
R2_ACCESS_KEY=
|
R2_ACCESS_KEY=
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
playwright-report
|
playwright-report
|
||||||
test-results
|
test-results
|
||||||
bknd.config.*
|
bknd.config.*
|
||||||
__test__/helper.d.ts
|
__test__/helper.d.ts
|
||||||
|
.env.local
|
||||||
Vendored
+12
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { App, createApp } from "core/test/utils";
|
||||||
import { getDummyConnection } from "./helper";
|
import { getDummyConnection } from "./helper";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import * as proto from "../src/data/prototype";
|
import * as proto from "../src/data/prototype";
|
||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
afterEach(afterAllCleanup);
|
afterEach(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||||
|
|
||||||
describe("App tests", async () => {
|
describe("App tests", async () => {
|
||||||
test("boots and pongs", async () => {
|
test("boots and pongs", async () => {
|
||||||
@@ -19,7 +22,7 @@ describe("App tests", async () => {
|
|||||||
test("plugins", async () => {
|
test("plugins", async () => {
|
||||||
const called: string[] = [];
|
const called: string[] = [];
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -105,7 +108,7 @@ describe("App tests", async () => {
|
|||||||
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
|
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only("drivers", async () => {
|
test("drivers", async () => {
|
||||||
const called: string[] = [];
|
const called: string[] = [];
|
||||||
const app = new App(dummyConnection, undefined, {
|
const app = new App(dummyConnection, undefined, {
|
||||||
drivers: {
|
drivers: {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
@@ -1,77 +1,76 @@
|
|||||||
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 * as adapter from "adapter";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
import { omitKeys } from "core/utils";
|
|
||||||
|
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
|
||||||
|
omitKeys(cfg, ["connection"]);
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("adapter", () => {
|
describe("adapter", () => {
|
||||||
it("makes config", async () => {
|
describe("makeConfig", () => {
|
||||||
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
|
it("returns empty config for empty inputs", async () => {
|
||||||
expect(
|
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
||||||
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
|
[{}],
|
||||||
).toEqual({});
|
[{}, { env: { TEST: "test" } }],
|
||||||
|
];
|
||||||
|
|
||||||
// merges everything returned from `app` with the config
|
for (const args of cases) {
|
||||||
expect(
|
const cfg = await adapter.makeConfig(...(args as any));
|
||||||
omitKeys(
|
expect(stripConnection(cfg)).toEqual({});
|
||||||
await adapter.makeConfig(
|
}
|
||||||
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
});
|
||||||
{ env: { TEST: "test" } },
|
|
||||||
),
|
|
||||||
["connection"],
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
initialConfig: { server: { cors: { origin: "test" } } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/* it.only("...", async () => {
|
it("merges app output into config", async () => {
|
||||||
const app = await adapter.createAdapterApp();
|
const cfg = await adapter.makeConfig(
|
||||||
}); */
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
|
{ env: { TEST: "test" } },
|
||||||
it("reuses apps correctly", async () => {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
|
|
||||||
const first = await adapter.createAdapterApp(
|
|
||||||
{
|
|
||||||
initialConfig: { server: { cors: { origin: "random" } } },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ id },
|
|
||||||
);
|
);
|
||||||
const second = await adapter.createAdapterApp();
|
|
||||||
const third = await adapter.createAdapterApp(undefined, undefined, { id });
|
|
||||||
|
|
||||||
await first.build();
|
expect(stripConnection(cfg)).toEqual({
|
||||||
await second.build();
|
config: { server: { cors: { origin: "test" } } },
|
||||||
await third.build();
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(first.toJSON().server.cors.origin).toEqual("random");
|
it("allows all properties in app() result", async () => {
|
||||||
expect(first).toBe(third);
|
const called = mock(() => null);
|
||||||
expect(first).not.toBe(second);
|
|
||||||
expect(second).not.toBe(third);
|
|
||||||
expect(second.toJSON().server.cors.origin).toEqual("*");
|
|
||||||
|
|
||||||
// recreate the first one
|
const cfg = await adapter.makeConfig(
|
||||||
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
|
{
|
||||||
await first2.build();
|
app: (env) => ({
|
||||||
expect(first2).not.toBe(first);
|
connection: { url: "test" },
|
||||||
expect(first2).not.toBe(third);
|
config: { server: { cors: { origin: "test" } } },
|
||||||
expect(first2).not.toBe(second);
|
options: { mode: "db" as const },
|
||||||
expect(first2.toJSON().server.cors.origin).toEqual("*");
|
onBuilt: () => {
|
||||||
});
|
called();
|
||||||
|
expect(env).toEqual({ foo: "bar" });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ foo: "bar" },
|
||||||
|
);
|
||||||
|
|
||||||
adapterTestSuite(bunTestRunner, {
|
expect(cfg.connection).toEqual({ url: "test" });
|
||||||
|
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||||
|
expect(cfg.options).toEqual({ mode: "db" });
|
||||||
|
|
||||||
|
await cfg.onBuilt?.({} as any);
|
||||||
|
expect(called).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("adapter test suites", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createFrameworkApp,
|
makeApp: adapter.createFrameworkApp,
|
||||||
label: "framework app",
|
label: "framework app",
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createRuntimeApp,
|
makeApp: adapter.createRuntimeApp,
|
||||||
label: "runtime app",
|
label: "runtime app",
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ describe("Api", async () => {
|
|||||||
it("should construct without options", () => {
|
it("should construct without options", () => {
|
||||||
const api = new Api();
|
const api = new Api();
|
||||||
expect(api.baseUrl).toBe("http://localhost");
|
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", () => {
|
it("should ignore force verify if no claims given", () => {
|
||||||
const api = new Api({ verified: true });
|
const api = new Api({ verified: true });
|
||||||
expect(api.baseUrl).toBe("http://localhost");
|
expect(api.baseUrl).toBe("http://localhost");
|
||||||
expect(api.isAuthVerified()).toBe(false);
|
expect(api.isAuthVerified()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should construct from request (token)", async () => {
|
it("should construct from request (token)", async () => {
|
||||||
@@ -42,7 +45,6 @@ describe("Api", async () => {
|
|||||||
expect(api.isAuthVerified()).toBe(false);
|
expect(api.isAuthVerified()).toBe(false);
|
||||||
|
|
||||||
const params = api.getParams();
|
const params = api.getParams();
|
||||||
console.log(params);
|
|
||||||
expect(params.token).toBe(token);
|
expect(params.token).toBe(token);
|
||||||
expect(params.token_transport).toBe("cookie");
|
expect(params.token_transport).toBe("cookie");
|
||||||
expect(params.host).toBe("http://example.com");
|
expect(params.host).toBe("http://example.com");
|
||||||
@@ -68,4 +70,10 @@ describe("Api", async () => {
|
|||||||
expect(params.token_transport).toBe("header");
|
expect(params.token_transport).toBe("header");
|
||||||
expect(params.host).toBe("http://another.com");
|
expect(params.host).toBe("http://another.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should extract tokens case insensitive", async () => {
|
||||||
|
const token = await sign({ sub: "test" }, "1234");
|
||||||
|
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
|
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="@types/bun" />
|
/// <reference types="@types/bun" />
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it, mock } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
|
import { getFileFromContext, isFile, isReadableStream, s, jsc } from "core/utils";
|
||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { assetsPath, assetsTmpPath } from "../helper";
|
import { assetsPath, assetsTmpPath } from "../helper";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ const mockedBackend = new Hono()
|
|||||||
.get("/file/:name", async (c) => {
|
.get("/file/:name", async (c) => {
|
||||||
const { name } = c.req.param();
|
const { name } = c.req.param();
|
||||||
const file = Bun.file(`${assetsPath}/${name}`);
|
const file = Bun.file(`${assetsPath}/${name}`);
|
||||||
return new Response(file, {
|
return new Response(new File([await file.bytes()], name, { type: file.type }), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": file.type,
|
"Content-Type": file.type,
|
||||||
"Content-Length": file.size.toString(),
|
"Content-Length": file.size.toString(),
|
||||||
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
|
|||||||
const res = await mockedBackend.request("/api/media/file/" + name);
|
const res = await mockedBackend.request("/api/media/file/" + name);
|
||||||
await Bun.write(path, res);
|
await Bun.write(path, res);
|
||||||
|
|
||||||
const file = await Bun.file(path);
|
const file = Bun.file(path);
|
||||||
expect(file.size).toBeGreaterThan(0);
|
expect(file.size).toBeGreaterThan(0);
|
||||||
expect(file.type).toBe("image/png");
|
expect(file.type).toBe("image/png");
|
||||||
await file.delete();
|
await file.delete();
|
||||||
@@ -98,7 +98,7 @@ describe("MediaApi", () => {
|
|||||||
expect(isReadableStream(res.body)).toBe(true);
|
expect(isReadableStream(res.body)).toBe(true);
|
||||||
expect(isReadableStream(res.res.body)).toBe(true);
|
expect(isReadableStream(res.res.body)).toBe(true);
|
||||||
|
|
||||||
const blob = await res.res.blob();
|
const blob = (await res.res.blob()) as File;
|
||||||
expect(isFile(blob)).toBe(true);
|
expect(isFile(blob)).toBe(true);
|
||||||
expect(blob.size).toBeGreaterThan(0);
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
expect(blob.type).toBe("image/png");
|
expect(blob.type).toBe("image/png");
|
||||||
@@ -110,10 +110,11 @@ describe("MediaApi", () => {
|
|||||||
const api = new MediaApi({}, mockedBackend.request);
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
|
||||||
const name = "image.png";
|
const name = "image.png";
|
||||||
const res = await api.getFileStream(name);
|
const res = await api.getFile(name);
|
||||||
expect(isReadableStream(res)).toBe(true);
|
const stream = await api.getFileStream(name);
|
||||||
|
expect(isReadableStream(stream)).toBe(true);
|
||||||
|
|
||||||
const blob = await new Response(res).blob();
|
const blob = (await res.res.blob()) as File;
|
||||||
expect(isFile(blob)).toBe(true);
|
expect(isFile(blob)).toBe(true);
|
||||||
expect(blob.size).toBeGreaterThan(0);
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
expect(blob.type).toBe("image/png");
|
expect(blob.type).toBe("image/png");
|
||||||
@@ -154,15 +155,38 @@ describe("MediaApi", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// upload via readable from bun
|
// 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
|
// upload via readable from response
|
||||||
{
|
{
|
||||||
const response = (await mockedBackend.request(url)) as Response;
|
const response = (await mockedBackend.request(url)) as Response;
|
||||||
await matches(
|
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
|
||||||
await api.upload(response.body!, { filename: "readable.png" }),
|
|
||||||
"readable.png",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should add overwrite query for entity upload", async (c) => {
|
||||||
|
const call = mock(() => null);
|
||||||
|
const hono = new Hono().post(
|
||||||
|
"/api/media/entity/:entity/:id/:field",
|
||||||
|
jsc("query", s.object({ overwrite: s.boolean().optional() })),
|
||||||
|
async (c) => {
|
||||||
|
const { overwrite } = c.req.valid("query");
|
||||||
|
expect(overwrite).toBe(true);
|
||||||
|
call();
|
||||||
|
return c.json({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const api = new MediaApi(
|
||||||
|
{
|
||||||
|
upload_fetcher: hono.request,
|
||||||
|
},
|
||||||
|
hono.request,
|
||||||
|
);
|
||||||
|
const file = Bun.file(`${assetsPath}/image.png`);
|
||||||
|
const res = await api.uploadToEntity("posts", 1, "cover", file as any, {
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(call).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 type { ModuleBuildContext } from "../../src";
|
||||||
import { App, createApp } from "core/test/utils";
|
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", () => {
|
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 () => {
|
test("seed includes ctx and app", async () => {
|
||||||
const called = mock(() => null);
|
const called = mock(() => null);
|
||||||
await createApp({
|
await createApp({
|
||||||
@@ -29,7 +43,7 @@ describe("App", () => {
|
|||||||
expect(called).toHaveBeenCalled();
|
expect(called).toHaveBeenCalled();
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
data: proto
|
data: proto
|
||||||
.em({
|
.em({
|
||||||
todos: proto.entity("todos", {
|
todos: proto.entity("todos", {
|
||||||
@@ -139,7 +153,7 @@ describe("App", () => {
|
|||||||
|
|
||||||
test("getMcpClient", async () => {
|
test("getMcpClient", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe("AppServer", () => {
|
|||||||
mcp: {
|
mcp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
path: "/api/system/mcp",
|
path: "/api/system/mcp",
|
||||||
|
logLevel: "warning",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ describe("AppServer", () => {
|
|||||||
mcp: {
|
mcp: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
path: "/api/system/mcp",
|
path: "/api/system/mcp",
|
||||||
|
logLevel: "warning",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,7 @@ describe("mcp auth", async () => {
|
|||||||
let server: McpServer;
|
let server: McpServer;
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
jwt: {
|
jwt: {
|
||||||
@@ -44,6 +44,7 @@ describe("mcp auth", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
server.setLogLevel("error");
|
server.setLogLevel("error");
|
||||||
server.onNotification((message) => {
|
server.onNotification((message) => {
|
||||||
@@ -200,7 +201,10 @@ describe("mcp auth", async () => {
|
|||||||
},
|
},
|
||||||
return_config: true,
|
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
|
// update role
|
||||||
await tool(server, "config_auth_roles_update", {
|
await tool(server, "config_auth_roles_update", {
|
||||||
@@ -209,13 +213,15 @@ describe("mcp auth", async () => {
|
|||||||
permissions: ["read"],
|
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
|
// get role
|
||||||
const getGuestRole = await tool(server, "config_auth_roles_get", {
|
const getGuestRole = await tool(server, "config_auth_roles_get", {
|
||||||
key: "guest",
|
key: "guest",
|
||||||
});
|
});
|
||||||
expect(getGuestRole.value.permissions).toEqual(["read"]);
|
expect(getGuestRole.value.permissions.map((p) => p.permission)).toEqual(["read"]);
|
||||||
|
|
||||||
// remove role
|
// remove role
|
||||||
await tool(server, "config_auth_roles_remove", {
|
await tool(server, "config_auth_roles_remove", {
|
||||||
|
|||||||
@@ -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 { createApp } from "core/test/utils";
|
||||||
import { registries } from "index";
|
import { registries } from "index";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("mcp", () => {
|
describe("mcp", () => {
|
||||||
it("should have tools", async () => {
|
it("should have tools", async () => {
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -30,6 +34,11 @@ describe("mcp", () => {
|
|||||||
});
|
});
|
||||||
await app.build();
|
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);
|
expect(app.mcp?.tools.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ describe("mcp data", async () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const time = performance.now();
|
const time = performance.now();
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -50,6 +50,7 @@ describe("mcp data", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
server.setLogLevel("error");
|
server.setLogLevel("error");
|
||||||
server.onNotification((message) => {
|
server.onNotification((message) => {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe("mcp media", async () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
adapter: {
|
adapter: {
|
||||||
@@ -39,6 +39,7 @@ describe("mcp media", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
server.setLogLevel("error");
|
server.setLogLevel("error");
|
||||||
server.onNotification((message) => {
|
server.onNotification((message) => {
|
||||||
|
|||||||
@@ -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 App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
import type { McpServer } from "bknd/utils";
|
import type { McpServer } from "bknd/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - [x] config_server_get
|
* - [x] config_server_get
|
||||||
@@ -11,7 +15,7 @@ describe("mcp system", async () => {
|
|||||||
let server: McpServer;
|
let server: McpServer;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -20,6 +24,7 @@ describe("mcp system", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { AppEvents } from "App";
|
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 App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
import type { McpServer } from "bknd/utils";
|
import type { McpServer } from "bknd/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - [x] system_config
|
* - [x] system_config
|
||||||
@@ -14,7 +18,7 @@ describe("mcp system", async () => {
|
|||||||
let server: McpServer;
|
let server: McpServer;
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = createApp({
|
app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -23,6 +27,7 @@ describe("mcp system", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
server = app.mcp!;
|
server = app.mcp!;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { registries } from "../../src";
|
||||||
import { createApp } from "core/test/utils";
|
import { createApp } from "core/test/utils";
|
||||||
import * as proto from "../../src/data/prototype";
|
import * as proto from "../../src/data/prototype";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("repros", async () => {
|
describe("repros", async () => {
|
||||||
/**
|
/**
|
||||||
@@ -72,7 +76,7 @@ describe("repros", async () => {
|
|||||||
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
|
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only("verify inversedBy", async () => {
|
test("verify inversedBy", async () => {
|
||||||
const schema = proto.em(
|
const schema = proto.em(
|
||||||
{
|
{
|
||||||
products: proto.entity("products", {
|
products: proto.entity("products", {
|
||||||
@@ -88,7 +92,7 @@ describe("repros", async () => {
|
|||||||
fns.relation(schema.product_likes).manyToOne(schema.users);
|
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();
|
await app.build();
|
||||||
|
|
||||||
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
||||||
|
|||||||
@@ -1,3 +1,64 @@
|
|||||||
|
import { Authenticator } from "auth/authenticate/Authenticator";
|
||||||
import { describe, expect, test } from "bun:test";
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("bearer token is case insensitive", async () => {
|
||||||
|
const auth = new Authenticator({}, null as any, {
|
||||||
|
jwt: {
|
||||||
|
secret: "secret",
|
||||||
|
fields: ["sub"],
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
sameSite: "strict",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const token = await auth.jwt({ sub: "test" });
|
||||||
|
|
||||||
|
const res = await auth.resolveAuthFromRequest(new Headers({
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}));
|
||||||
|
expect((res as any).sub).toBe("test")
|
||||||
|
|
||||||
|
const res2 = await auth.resolveAuthFromRequest(new Headers({
|
||||||
|
Authorization: `bearer ${token}`,
|
||||||
|
}));
|
||||||
|
expect((res2 as any).sub).toBe("test")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
||||||
import { Guard } from "../../../src/auth/authorize/Guard";
|
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", () => {
|
describe("authorize", () => {
|
||||||
|
const read = new Permission("read", {
|
||||||
|
filterable: true,
|
||||||
|
});
|
||||||
|
const write = new Permission("write");
|
||||||
|
|
||||||
test("basic", async () => {
|
test("basic", async () => {
|
||||||
const guard = Guard.create(
|
const guard = createGuard(
|
||||||
["read", "write"],
|
["read", "write"],
|
||||||
{
|
{
|
||||||
admin: {
|
admin: {
|
||||||
@@ -16,14 +42,14 @@ describe("authorize", () => {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBeUndefined();
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBeUndefined();
|
||||||
|
|
||||||
expect(() => guard.granted("something")).toThrow();
|
expect(() => guard.granted(new Permission("something"), {})).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with default", async () => {
|
test("with default", async () => {
|
||||||
const guard = Guard.create(
|
const guard = createGuard(
|
||||||
["read", "write"],
|
["read", "write"],
|
||||||
{
|
{
|
||||||
admin: {
|
admin: {
|
||||||
@@ -37,26 +63,26 @@ describe("authorize", () => {
|
|||||||
{ enabled: true },
|
{ enabled: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read, {})).toBeUndefined();
|
||||||
expect(guard.granted("write")).toBe(false);
|
expect(() => guard.granted(write, {})).toThrow();
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBeUndefined();
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard implicit allow", async () => {
|
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(read, {})).toBeUndefined();
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted(write, {})).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("role implicit allow", async () => {
|
test("role implicit allow", async () => {
|
||||||
const guard = Guard.create(["read", "write"], {
|
const guard = createGuard(["read", "write"], {
|
||||||
admin: {
|
admin: {
|
||||||
implicit_allow: true,
|
implicit_allow: true,
|
||||||
},
|
},
|
||||||
@@ -66,12 +92,12 @@ describe("authorize", () => {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(guard.granted("read", user)).toBe(true);
|
expect(guard.granted(read, user)).toBeUndefined();
|
||||||
expect(guard.granted("write", user)).toBe(true);
|
expect(guard.granted(write, user)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard with guest role implicit allow", async () => {
|
test("guard with guest role implicit allow", async () => {
|
||||||
const guard = Guard.create(["read", "write"], {
|
const guard = createGuard(["read", "write"], {
|
||||||
guest: {
|
guest: {
|
||||||
implicit_allow: true,
|
implicit_allow: true,
|
||||||
is_default: true,
|
is_default: true,
|
||||||
@@ -79,7 +105,143 @@ describe("authorize", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(guard.getUserRole()?.name).toBe("guest");
|
expect(guard.getUserRole()?.name).toBe("guest");
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted(read, {})).toBeUndefined();
|
||||||
expect(guard.granted("write")).toBe(true);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
|
||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
|
||||||
|
describe("PasswordStrategy", () => {
|
||||||
|
it("should enforce provided minimum length", async () => {
|
||||||
|
const strategy = new PasswordStrategy({ minLength: 8, hashing: "plain" });
|
||||||
|
|
||||||
|
expect(strategy.verify("password")({} as any)).rejects.toThrow();
|
||||||
|
expect(
|
||||||
|
strategy.verify("password1234")({ strategy_value: "password1234" } as any),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
||||||
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
|
import { Event, EventManager, InvalidEventReturn, NoParamEvent } from "../../src/core/events";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|||||||
@@ -66,4 +66,14 @@ describe("object-query", () => {
|
|||||||
expect(result).toBe(expected);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -194,6 +194,182 @@ describe("Core Utils", async () => {
|
|||||||
expect(result).toEqual(expected);
|
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 () => {
|
describe("file", async () => {
|
||||||
@@ -248,7 +424,7 @@ describe("Core Utils", async () => {
|
|||||||
expect(utils.getContentName(request)).toBe(name);
|
expect(utils.getContentName(request)).toBe(name);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only("detectImageDimensions", async () => {
|
test("detectImageDimensions", async () => {
|
||||||
// wrong
|
// wrong
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
|
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
|
||||||
@@ -264,15 +440,44 @@ describe("Core Utils", async () => {
|
|||||||
height: 512,
|
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", () => {
|
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");
|
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(utils.datetimeStringUTC());
|
||||||
console.log(new Date());
|
console.log(new Date());
|
||||||
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone);
|
console.log("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone); */
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { parse } from "core/utils/schema";
|
|||||||
|
|
||||||
import { DataController } from "../../src/data/api/DataController";
|
import { DataController } from "../../src/data/api/DataController";
|
||||||
import { dataConfigSchema } from "../../src/data/data-schema";
|
import { dataConfigSchema } from "../../src/data/data-schema";
|
||||||
import { 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 { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
|
||||||
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
|
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
|
||||||
import { Entity, EntityManager, type EntityData } from "data/entities";
|
import { Entity, EntityManager, type EntityData } from "data/entities";
|
||||||
@@ -13,7 +14,7 @@ import { TextField } from "data/fields";
|
|||||||
import { ManyToOneRelation } from "data/relations";
|
import { ManyToOneRelation } from "data/relations";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||||
|
|
||||||
const dataConfig = parse(dataConfigSchema, {});
|
const dataConfig = parse(dataConfigSchema, {});
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ describe("some tests", async () => {
|
|||||||
const query = await em.repository(users).findId(1);
|
const query = await em.repository(users).findId(1);
|
||||||
|
|
||||||
expect(query.sql).toBe(
|
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();
|
expect(query.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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, 500, 20);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
disableConsoleLog();
|
||||||
|
}, 30000);
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Entity, EntityManager } from "data/entities";
|
||||||
import { ManyToOneRelation } from "data/relations";
|
import { ManyToOneRelation } from "data/relations";
|
||||||
import { TextField } from "data/fields";
|
import { TextField } from "data/fields";
|
||||||
import { JoinBuilder } from "data/entities/query/JoinBuilder";
|
import { JoinBuilder } from "data/entities/query/JoinBuilder";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
afterAll(afterAllCleanup);
|
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||||
|
|
||||||
describe("[data] JoinBuilder", async () => {
|
describe("[data] JoinBuilder", async () => {
|
||||||
test("missing relation", async () => {
|
test("missing relation", async () => {
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import {
|
|||||||
} from "data/relations";
|
} from "data/relations";
|
||||||
import { NumberField, TextField } from "data/fields";
|
import { NumberField, TextField } from "data/fields";
|
||||||
import * as proto from "data/prototype";
|
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";
|
import { MutatorEvents } from "data/events";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
afterAll(afterAllCleanup);
|
afterAll(afterAllCleanup);
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||||
|
|
||||||
describe("[data] Mutator (base)", async () => {
|
describe("[data] Mutator (base)", async () => {
|
||||||
|
|||||||
@@ -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 type { Kysely, Transaction } from "kysely";
|
||||||
import { TextField } from "data/fields";
|
import { TextField } from "data/fields";
|
||||||
import { em as $em, entity as $entity, text as $text } from "data/prototype";
|
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 { ManyToOneRelation } from "data/relations";
|
||||||
import { RepositoryEvents } from "data/events";
|
import { RepositoryEvents } from "data/events";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
type E = Kysely<any> | Transaction<any>;
|
type E = Kysely<any> | Transaction<any>;
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
afterAll(afterAllCleanup);
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||||
|
|
||||||
async function sleep(ms: number) {
|
async function sleep(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -122,6 +124,81 @@ describe("[Repository]", async () => {
|
|||||||
.then((r) => [r.count, r.total]),
|
.then((r) => [r.count, r.total]),
|
||||||
).resolves.toEqual([undefined, undefined]);
|
).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 () => {
|
describe("[data] Repository (Events)", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// eslint-disable-next-line import/no-unresolved
|
// 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 { randomString } from "core/utils";
|
||||||
import { Entity, EntityManager } from "data/entities";
|
import { Entity, EntityManager } from "data/entities";
|
||||||
import { TextField, EntityIndex } from "data/fields";
|
import { TextField, EntityIndex } from "data/fields";
|
||||||
@@ -268,4 +268,39 @@ describe("SchemaManager tests", async () => {
|
|||||||
const diffAfter = await em.schema().getDiff();
|
const diffAfter = await em.schema().getDiff();
|
||||||
expect(diffAfter.length).toBe(0);
|
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 */
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { Entity, EntityManager } from "data/entities";
|
||||||
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
|
import { ManyToManyRelation, ManyToOneRelation, PolymorphicRelation } from "data/relations";
|
||||||
import { TextField } from "data/fields";
|
import { TextField } from "data/fields";
|
||||||
@@ -6,6 +6,10 @@ import * as proto from "data/prototype";
|
|||||||
import { WithBuilder } from "data/entities/query/WithBuilder";
|
import { WithBuilder } from "data/entities/query/WithBuilder";
|
||||||
import { schemaToEm } from "../../helper";
|
import { schemaToEm } from "../../helper";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
|
||||||
@@ -84,7 +88,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
|
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "author"."id" asc limit ?) as obj) as "author" from "posts"',
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual([1]);
|
expect(res2.parameters).toEqual([1]);
|
||||||
});
|
});
|
||||||
@@ -188,9 +192,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ single: {} },
|
{ single: {} },
|
||||||
);
|
);
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
expect(res.sql).toBe(
|
expect(res.sql).toMatchSnapshot();
|
||||||
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
|
|
||||||
);
|
|
||||||
expect(res.parameters).toEqual(["categories.single", 1]);
|
expect(res.parameters).toEqual(["categories.single", 1]);
|
||||||
|
|
||||||
const qb2 = WithBuilder.addClause(
|
const qb2 = WithBuilder.addClause(
|
||||||
@@ -200,9 +202,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ multiple: {} },
|
{ multiple: {} },
|
||||||
);
|
);
|
||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toMatchSnapshot();
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"',
|
|
||||||
);
|
|
||||||
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
|
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,9 +267,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
//prettyPrintQb(qb);
|
//prettyPrintQb(qb);
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toMatchSnapshot();
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
|
|
||||||
);
|
|
||||||
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,9 +307,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toMatchSnapshot();
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
|
|
||||||
);
|
|
||||||
expect(qb.compile().parameters).toEqual([1, 12, 0]);
|
expect(qb.compile().parameters).toEqual([1, 12, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder recursive compiles with singles 1`] = `"select (select json_object('id', "obj"."id", 'username', "obj"."username", 'avatar', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "avatar"."id" as "id", "avatar"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "avatar"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder recursive compiles with many 1`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'posts_id', "agg"."posts_id", 'users_id', "agg"."users_id", 'users', "agg"."users")), '[]') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object('username', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder polymorphic 1`] = `"select (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "single"."id" as "id", "single"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "single"."id" asc limit ?) as obj) as "single" from "categories""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder polymorphic 2`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'path', "agg"."path")), '[]') from (select "multiple"."id" as "id", "multiple"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "multiple"."id" asc limit ? offset ?) as agg) as "multiple" from "categories""`;
|
||||||
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: false,
|
hasDefaultValue: true,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: false,
|
hasDefaultValue: true,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -23,11 +23,4 @@ describe("FieldIndex", async () => {
|
|||||||
expect(index.name).toEqual("idx_test_name");
|
expect(index.name).toEqual("idx_test_name");
|
||||||
expect(index.unique).toEqual(false);
|
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");
|
const field = new JsonField("test");
|
||||||
fieldTestSuite(bunTestRunner, JsonField, {
|
fieldTestSuite(bunTestRunner, JsonField, {
|
||||||
defaultValue: { a: 1 },
|
defaultValue: { a: 1 },
|
||||||
sampleValues: ["string", { test: 1 }, 1],
|
//sampleValues: ["string", { test: 1 }, 1],
|
||||||
schemaType: "text",
|
schemaType: "text",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@ describe("[data] JsonField", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getValue", async () => {
|
test("getValue", async () => {
|
||||||
expect(field.getValue({ test: 1 }, "form")).toBe('{\n "test": 1\n}');
|
expect(field.getValue({ test: 1 }, "form")).toEqual({ test: 1 });
|
||||||
expect(field.getValue("string", "form")).toBe('"string"');
|
expect(field.getValue("string", "form")).toBe("string");
|
||||||
expect(field.getValue(1, "form")).toBe("1");
|
expect(field.getValue(1, "form")).toBe(1);
|
||||||
|
|
||||||
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
|
expect(field.getValue('{"test":1}', "submit")).toEqual({ test: 1 });
|
||||||
expect(field.getValue('"string"', "submit")).toBe("string");
|
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({ test: 1 }, "table")).toBe('{"test":1}');
|
||||||
expect(field.getValue("string", "table")).toBe('"string"');
|
expect(field.getValue("string", "table")).toBe('"string"');
|
||||||
expect(field.getValue(1, "form")).toBe("1");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
type BaseRelationConfig,
|
type BaseRelationConfig,
|
||||||
EntityRelation,
|
EntityRelation,
|
||||||
EntityRelationAnchor,
|
EntityRelationAnchor,
|
||||||
|
ManyToManyRelation,
|
||||||
RelationTypes,
|
RelationTypes,
|
||||||
} from "data/relations";
|
} from "data/relations";
|
||||||
|
import * as proto from "data/prototype";
|
||||||
|
|
||||||
class TestEntityRelation extends EntityRelation {
|
class TestEntityRelation extends EntityRelation {
|
||||||
constructor(config?: BaseRelationConfig) {
|
constructor(config?: BaseRelationConfig) {
|
||||||
@@ -75,4 +77,15 @@ describe("[data] EntityRelation", async () => {
|
|||||||
const relation2 = new TestEntityRelation({ required: true });
|
const relation2 = new TestEntityRelation({ required: true });
|
||||||
expect(relation2.required).toBe(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
@@ -39,26 +39,6 @@ export function getLocalLibsqlConnection() {
|
|||||||
return { url: "http://127.0.0.1:8080" };
|
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>) {
|
export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||||
const { sql, parameters } = qb.compile();
|
const { sql, parameters } = qb.compile();
|
||||||
return { sql, parameters };
|
return { sql, parameters };
|
||||||
@@ -66,7 +46,7 @@ export function compileQb(qb: SelectQueryBuilder<any, any, any>) {
|
|||||||
|
|
||||||
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
|
export function prettyPrintQb(qb: SelectQueryBuilder<any, any, any>) {
|
||||||
const { sql, parameters } = qb.compile();
|
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> {
|
export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): EntityManager<any> {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { App, createApp } from "../../src";
|
import { App, createApp, type AuthResponse } from "../../src";
|
||||||
import type { AuthResponse } from "../../src/auth";
|
import { auth } from "../../src/modules/middlewares";
|
||||||
import { auth } from "../../src/auth/middlewares";
|
import {
|
||||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
mergeObject,
|
||||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
randomString,
|
||||||
|
secureRandomString,
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
withDisabledConsole,
|
||||||
afterEach(afterAllCleanup);
|
} from "../../src/core/utils";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
import { getDummyConnection } from "../helper";
|
||||||
|
import type { AppAuthSchema } from "auth/auth-schema";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -65,11 +68,12 @@ const configs = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function createAuthApp() {
|
function createAuthApp(config?: Partial<AppAuthSchema>) {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: configs.auth,
|
auth: mergeObject(configs.auth, config ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +138,16 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
|
|||||||
|
|
||||||
return { res, data };
|
return { res, data };
|
||||||
},
|
},
|
||||||
|
register: async (user: any): Promise<{ res: Response; data: AuthResponse }> => {
|
||||||
|
const res = (await app.server.request("/api/auth/password/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers(),
|
||||||
|
body: body(user),
|
||||||
|
})) as Response;
|
||||||
|
const data = mode === "cookie" ? getCookie(res, "auth") : await res.json();
|
||||||
|
|
||||||
|
return { res, data };
|
||||||
|
},
|
||||||
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
me: async (token?: string): Promise<Pick<AuthResponse, "user">> => {
|
||||||
const res = (await app.server.request("/api/auth/me", {
|
const res = (await app.server.request("/api/auth/me", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -151,8 +165,8 @@ describe("integration auth", () => {
|
|||||||
|
|
||||||
const { data: users } = await app.em.repository("users").findMany();
|
const { data: users } = await app.em.repository("users").findMany();
|
||||||
expect(users.length).toBe(2);
|
expect(users.length).toBe(2);
|
||||||
expect(users[0].email).toBe(configs.users.normal.email);
|
expect(users[0]?.email).toBe(configs.users.normal.email);
|
||||||
expect(users[1].email).toBe(configs.users.admin.email);
|
expect(users[1]?.email).toBe(configs.users.admin.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should log you in with API", async () => {
|
it("should log you in with API", async () => {
|
||||||
@@ -223,7 +237,7 @@ describe("integration auth", () => {
|
|||||||
|
|
||||||
app.server.get("/get", auth(), async (c) => {
|
app.server.get("/get", auth(), async (c) => {
|
||||||
return c.json({
|
return c.json({
|
||||||
user: c.get("auth").user ?? null,
|
user: c.get("auth")?.user ?? null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.server.get("/wait", auth(), async (c) => {
|
app.server.get("/wait", auth(), async (c) => {
|
||||||
@@ -242,9 +256,66 @@ describe("integration auth", () => {
|
|||||||
{
|
{
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
const res = await app.server.request("/get");
|
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(data.user).toBe(null);
|
||||||
expect(await $fns.me()).toEqual({ user: null as any });
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should register users with default role", async () => {
|
||||||
|
const app = createAuthApp({ default_role_register: "guest" });
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
// takes default role
|
||||||
|
expect(
|
||||||
|
await app
|
||||||
|
.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
})
|
||||||
|
.then((r) => r.role),
|
||||||
|
).toBe("guest");
|
||||||
|
|
||||||
|
// throws error if role doesn't exist
|
||||||
|
expect(
|
||||||
|
app.createUser({
|
||||||
|
email: "test@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "doesnt exist",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// takes role if provided
|
||||||
|
expect(
|
||||||
|
await app
|
||||||
|
.createUser({
|
||||||
|
email: "test2@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.then((r) => r.role),
|
||||||
|
).toBe("admin");
|
||||||
|
|
||||||
|
// registering with role is not allowed
|
||||||
|
expect(
|
||||||
|
await $fns
|
||||||
|
.register({
|
||||||
|
email: "test3@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.then((r) => r.res.ok),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// takes default role
|
||||||
|
expect(
|
||||||
|
await $fns
|
||||||
|
.register({
|
||||||
|
email: "test3@bknd.io",
|
||||||
|
password: "12345678",
|
||||||
|
})
|
||||||
|
.then((r) => r.data.user.role),
|
||||||
|
).toBe("guest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { createApp } from "core/test/utils";
|
||||||
import { Api } from "../../src/Api";
|
import { Api } from "../../src/Api";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("integration config", () => {
|
describe("integration config", () => {
|
||||||
it("should create an entity", async () => {
|
it("should create an entity", async () => {
|
||||||
|
|||||||
@@ -6,17 +6,21 @@ import { createApp } from "core/test/utils";
|
|||||||
import { mergeObject, randomString } from "../../src/core/utils";
|
import { mergeObject, randomString } from "../../src/core/utils";
|
||||||
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
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";
|
||||||
|
import * as proto from "data/prototype";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
disableConsoleLog();
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
});
|
});
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
const path = `${assetsPath}/image.png`;
|
const path = `${assetsPath}/image.png`;
|
||||||
|
|
||||||
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
media: mergeObject(
|
media: mergeObject(
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -40,9 +44,6 @@ function makeName(ext: string) {
|
|||||||
return randomString(10) + "." + ext;
|
return randomString(10) + "." + ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("MediaController", () => {
|
describe("MediaController", () => {
|
||||||
test("accepts direct", async () => {
|
test("accepts direct", async () => {
|
||||||
const app = await makeApp();
|
const app = await makeApp();
|
||||||
@@ -94,4 +95,121 @@ describe("MediaController", () => {
|
|||||||
expect(res.status).toBe(413);
|
expect(res.status).toBe(413);
|
||||||
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("entity upload with max_items and overwrite", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
media: mergeObject(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
adapter: {
|
||||||
|
type: "local",
|
||||||
|
config: {
|
||||||
|
path: assetsTmpPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
entities: {
|
||||||
|
posts: proto
|
||||||
|
.entity("posts", {
|
||||||
|
title: proto.text(),
|
||||||
|
cover: proto.medium(),
|
||||||
|
})
|
||||||
|
.toJSON(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
// create a post first
|
||||||
|
const createRes = await app.server.request("/api/data/entity/posts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: "Test Post" }),
|
||||||
|
});
|
||||||
|
expect(createRes.status).toBe(201);
|
||||||
|
const { data: post } = (await createRes.json()) as any;
|
||||||
|
|
||||||
|
const file = Bun.file(path);
|
||||||
|
const uploadedFiles: string[] = [];
|
||||||
|
|
||||||
|
// upload first file to entity (should succeed)
|
||||||
|
const res1 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
expect(res1.status).toBe(201);
|
||||||
|
const result1 = (await res1.json()) as any;
|
||||||
|
uploadedFiles.push(result1.name);
|
||||||
|
|
||||||
|
// upload second file without overwrite (should fail - max_items reached)
|
||||||
|
const res2 = await app.server.request(`/api/media/entity/posts/${post.id}/cover`, {
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
expect(res2.status).toBe(400);
|
||||||
|
const result2 = (await res2.json()) as any;
|
||||||
|
expect(result2.error).toContain("Max items");
|
||||||
|
|
||||||
|
// upload third file with overwrite=true (should succeed and delete old file)
|
||||||
|
const res3 = await app.server.request(
|
||||||
|
`/api/media/entity/posts/${post.id}/cover?overwrite=true`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: file,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(res3.status).toBe(201);
|
||||||
|
const result3 = (await res3.json()) as any;
|
||||||
|
uploadedFiles.push(result3.name);
|
||||||
|
|
||||||
|
// verify old file was deleted from storage
|
||||||
|
const oldFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[0]);
|
||||||
|
expect(await oldFile.exists()).toBe(false);
|
||||||
|
|
||||||
|
// verify new file exists
|
||||||
|
const newFile = Bun.file(assetsTmpPath + "/" + uploadedFiles[1]);
|
||||||
|
expect(await newFile.exists()).toBe(true);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
await newFile.delete();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ describe("media/mime-types", () => {
|
|||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
["text/tab-separated-values", "tsv"],
|
["text/tab-separated-values", "tsv"],
|
||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
|
["application/pdf", "pdf"],
|
||||||
|
["audio/mpeg", "mp3"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const [mime, ext] of tests) {
|
for (const [mime, ext] of tests) {
|
||||||
@@ -88,6 +90,9 @@ describe("media/mime-types", () => {
|
|||||||
["image.jpeg", "jpeg"],
|
["image.jpeg", "jpeg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||||
|
["file.pdf", "pdf"],
|
||||||
|
["file.mp3", "mp3"],
|
||||||
|
["robots.txt", "txt"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const [filename, ext] of tests) {
|
for (const [filename, ext] of tests) {
|
||||||
@@ -102,4 +107,36 @@ describe("media/mime-types", () => {
|
|||||||
const [, ext] = getRandomizedFilename(file).split(".");
|
const [, ext] = getRandomizedFilename(file).split(".");
|
||||||
expect(ext).toBe("jpg");
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ import { createApp } from "core/test/utils";
|
|||||||
import { AuthController } from "../../src/auth/api/AuthController";
|
import { AuthController } from "../../src/auth/api/AuthController";
|
||||||
import { em, entity, make, text } from "data/prototype";
|
import { em, entity, make, text } from "data/prototype";
|
||||||
import { AppAuth, type ModuleBuildContext } from "modules";
|
import { AppAuth, type ModuleBuildContext } from "modules";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
|
||||||
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("AppAuth", () => {
|
describe("AppAuth", () => {
|
||||||
test.only("...", () => {
|
|
||||||
const auth = new AppAuth({});
|
|
||||||
console.log(auth.toJSON());
|
|
||||||
console.log(auth.config);
|
|
||||||
});
|
|
||||||
|
|
||||||
moduleTestSuite(AppAuth);
|
moduleTestSuite(AppAuth);
|
||||||
|
|
||||||
let ctx: ModuleBuildContext;
|
let ctx: ModuleBuildContext;
|
||||||
@@ -36,11 +33,9 @@ describe("AppAuth", () => {
|
|||||||
await auth.build();
|
await auth.build();
|
||||||
|
|
||||||
const oldConfig = auth.toJSON(true);
|
const oldConfig = auth.toJSON(true);
|
||||||
//console.log(oldConfig);
|
|
||||||
await auth.schema().patch("enabled", true);
|
await auth.schema().patch("enabled", true);
|
||||||
await auth.build();
|
await auth.build();
|
||||||
const newConfig = auth.toJSON(true);
|
const newConfig = auth.toJSON(true);
|
||||||
//console.log(newConfig);
|
|
||||||
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +61,6 @@ describe("AppAuth", () => {
|
|||||||
const app = new AuthController(auth).getController();
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
disableConsoleLog();
|
|
||||||
const res = await app.request("/password/register", {
|
const res = await app.request("/password/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -77,7 +71,6 @@ describe("AppAuth", () => {
|
|||||||
password: "12345678",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
enableConsoleLog();
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const { data: users } = await ctx.em.repository("users").findMany();
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
@@ -116,7 +109,6 @@ describe("AppAuth", () => {
|
|||||||
const app = new AuthController(auth).getController();
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
disableConsoleLog();
|
|
||||||
const res = await app.request("/password/register", {
|
const res = await app.request("/password/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -127,7 +119,6 @@ describe("AppAuth", () => {
|
|||||||
password: "12345678",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
enableConsoleLog();
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const { data: users } = await ctx.em.repository("users").findMany();
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
@@ -147,7 +138,7 @@ describe("AppAuth", () => {
|
|||||||
|
|
||||||
test("registers auth middleware for bknd routes only", async () => {
|
test("registers auth middleware for bknd routes only", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
jwt: {
|
jwt: {
|
||||||
@@ -158,7 +149,7 @@ describe("AppAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
app.registerAdminController();
|
app.registerAdminController({ forceDev: true });
|
||||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
// register custom route
|
// register custom route
|
||||||
@@ -177,7 +168,7 @@ describe("AppAuth", () => {
|
|||||||
|
|
||||||
test("should allow additional user fields", async () => {
|
test("should allow additional user fields", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
entity_name: "users",
|
entity_name: "users",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -201,7 +192,7 @@ describe("AppAuth", () => {
|
|||||||
|
|
||||||
test("ensure user field configs is always correct", async () => {
|
test("ensure user field configs is always correct", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
@@ -232,4 +223,32 @@ describe("AppAuth", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("default role for registration must be a valid role", async () => {
|
||||||
|
const app = createApp({
|
||||||
|
config: {
|
||||||
|
auth: {
|
||||||
|
enabled: true,
|
||||||
|
jwt: {
|
||||||
|
secret: "123456",
|
||||||
|
},
|
||||||
|
allow_register: true,
|
||||||
|
roles: {
|
||||||
|
guest: {
|
||||||
|
is_default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const auth = app.module.auth;
|
||||||
|
// doesn't allow invalid role
|
||||||
|
expect(auth.schema().patch("default_role_register", "admin")).rejects.toThrow();
|
||||||
|
// allows valid role
|
||||||
|
await auth.schema().patch("default_role_register", "guest");
|
||||||
|
expect(auth.toJSON().default_role_register).toBe("guest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { createApp } from "core/test/utils";
|
||||||
import { em, entity, text } from "data/prototype";
|
import { em, entity, text } from "data/prototype";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/media/AppMedia";
|
import { AppMedia } from "../../src/media/AppMedia";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { moduleTestSuite } from "./module-test-suite";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("AppMedia", () => {
|
describe("AppMedia", () => {
|
||||||
test.only("...", () => {
|
test.skip("...", () => {
|
||||||
const media = new AppMedia();
|
const media = new AppMedia();
|
||||||
console.log(media.toJSON());
|
console.log(media.toJSON());
|
||||||
});
|
});
|
||||||
@@ -18,7 +22,7 @@ describe("AppMedia", () => {
|
|||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
entity_name: "media",
|
entity_name: "media",
|
||||||
enabled: true,
|
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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
|
import { getDefaultConfig } from "modules/ModuleManager";
|
||||||
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
|
import { type ConfigTable, DbModuleManager as ModuleManager } from "modules/db/DbModuleManager";
|
||||||
|
|
||||||
|
import { CURRENT_VERSION, TABLE_NAME } from "modules/db/migrations";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
import { s, stripMark } from "core/utils/schema";
|
import { s, stripMark } from "core/utils/schema";
|
||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
import { entity, text } from "data/prototype";
|
import { entity, text } from "data/prototype";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("ModuleManager", async () => {
|
describe("ModuleManager", async () => {
|
||||||
test("s1: no config, no build", async () => {
|
test("s1: no config, no build", async () => {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
@@ -77,7 +82,6 @@ describe("ModuleManager", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
//const { version, ...json } = mm.toJSON() as any;
|
|
||||||
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const db = dummyConnection.kysely;
|
const db = dummyConnection.kysely;
|
||||||
@@ -92,10 +96,6 @@ describe("ModuleManager", async () => {
|
|||||||
|
|
||||||
await mm2.build();
|
await mm2.build();
|
||||||
|
|
||||||
/* console.log({
|
|
||||||
json,
|
|
||||||
configs: mm2.configs(),
|
|
||||||
}); */
|
|
||||||
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
||||||
expect(mm2.configs().data.entities?.test).toBeDefined();
|
expect(mm2.configs().data.entities?.test).toBeDefined();
|
||||||
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
||||||
@@ -133,7 +133,7 @@ describe("ModuleManager", async () => {
|
|||||||
const db = c2.dummyConnection.kysely;
|
const db = c2.dummyConnection.kysely;
|
||||||
|
|
||||||
const mm2 = new ModuleManager(c2.dummyConnection, {
|
const mm2 = new ModuleManager(c2.dummyConnection, {
|
||||||
initial: { version: version - 1, ...json },
|
initial: { version: version - 1, ...json } as any,
|
||||||
});
|
});
|
||||||
await mm2.syncConfigTable();
|
await mm2.syncConfigTable();
|
||||||
await db
|
await db
|
||||||
@@ -223,8 +223,6 @@ describe("ModuleManager", async () => {
|
|||||||
const c = getDummyConnection();
|
const c = getDummyConnection();
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
await mm.build();
|
await mm.build();
|
||||||
console.log("==".repeat(30));
|
|
||||||
console.log("");
|
|
||||||
const json = mm.configs();
|
const json = mm.configs();
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
const c2 = getDummyConnection();
|
||||||
@@ -270,7 +268,6 @@ describe("ModuleManager", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
//console.log("building FailingModule", this.config);
|
|
||||||
if (this.config.value && this.config.value < 0) {
|
if (this.config.value && this.config.value < 0) {
|
||||||
throw new Error("value must be positive, given: " + this.config.value);
|
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 () => {
|
test("it builds", async () => {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const mm = new TestModuleManager(dummyConnection);
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { type InitialModuleConfigs, createApp } from "../../../src";
|
import { App, type InitialModuleConfigs, createApp } from "/";
|
||||||
|
|
||||||
import { type Kysely, sql } from "kysely";
|
import { type Kysely, sql } from "kysely";
|
||||||
import { getDummyConnection } from "../../helper";
|
import { getDummyConnection } from "../../helper";
|
||||||
import v7 from "./samples/v7.json";
|
import v7 from "./samples/v7.json";
|
||||||
import v8 from "./samples/v8.json";
|
import v8 from "./samples/v8.json";
|
||||||
import v8_2 from "./samples/v8-2.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
|
// 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();
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
|
||||||
if (!("version" in config)) throw new Error("config must have a version");
|
if (!("version" in config)) throw new Error("config must have a version");
|
||||||
@@ -34,6 +42,10 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
|||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
if (opts?.beforeCreateApp) {
|
||||||
|
await opts.beforeCreateApp(db);
|
||||||
|
}
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
});
|
});
|
||||||
@@ -41,6 +53,19 @@ async function createVersionedApp(config: InitialModuleConfigs | any) {
|
|||||||
return app;
|
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", () => {
|
describe("Migrations", () => {
|
||||||
/**
|
/**
|
||||||
* updated auth strategies to have "enabled" prop
|
* updated auth strategies to have "enabled" prop
|
||||||
@@ -78,4 +103,30 @@ describe("Migrations", () => {
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
expect(app.toJSON(true).server.admin).toBeUndefined();
|
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": {} }
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { AppReduced, type AppType } from "ui/client/utils/AppReduced";
|
||||||
|
import type { BkndAdminProps } from "ui/Admin";
|
||||||
|
|
||||||
|
// Import the normalizeAdminPath function for testing
|
||||||
|
// Note: This assumes the function is exported or we need to test it indirectly through public methods
|
||||||
|
|
||||||
|
describe("AppReduced", () => {
|
||||||
|
let mockAppJson: AppType;
|
||||||
|
let appReduced: AppReduced;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAppJson = {
|
||||||
|
data: {
|
||||||
|
entities: {},
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
flows: {
|
||||||
|
flows: {},
|
||||||
|
},
|
||||||
|
auth: {},
|
||||||
|
} as AppType;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSettingsPath", () => {
|
||||||
|
it("should return settings path with basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return settings path with empty basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should append additional path segments", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["user", "profile"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/user/profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize multiple slashes", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "//admin//",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["//user//"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle basepath without leading slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAbsolutePath", () => {
|
||||||
|
it("should return absolute path with basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("dashboard");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return base path when no path provided", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize paths correctly", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "//admin//",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("//dashboard//");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("options getter", () => {
|
||||||
|
it("should return merged options with defaults", () => {
|
||||||
|
const customOptions: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/custom-admin",
|
||||||
|
logo_return_path: "/custom-home",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, customOptions);
|
||||||
|
const options = appReduced.options;
|
||||||
|
|
||||||
|
expect(options).toEqual({
|
||||||
|
logo_return_path: "/custom-home",
|
||||||
|
basepath: "/custom-admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default logo_return_path when not provided", () => {
|
||||||
|
const customOptions: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, customOptions);
|
||||||
|
const options = appReduced.options;
|
||||||
|
|
||||||
|
expect(options.logo_return_path).toBe("/");
|
||||||
|
expect(options.basepath).toBe("/admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path normalization behavior", () => {
|
||||||
|
it("should normalize duplicate slashes in settings path", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["//nested//path//"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/nested/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle root path normalization", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath();
|
||||||
|
|
||||||
|
// The normalizeAdminPath function removes trailing slashes except for root "/"
|
||||||
|
// When basepath is "/", the result is "~/" which becomes "~" after normalization
|
||||||
|
expect(result).toBe("~");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve entity paths ending with slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("entity/");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/entity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove trailing slashes from non-entity paths", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("dashboard/");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withBasePath - double slash fix (admin_basepath with trailing slash)", () => {
|
||||||
|
it("should not produce double slashes when admin_basepath has trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.withBasePath("/data");
|
||||||
|
|
||||||
|
expect(result).toBe("/admin/data");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work correctly when admin_basepath has no trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.withBasePath("/data");
|
||||||
|
|
||||||
|
expect(result).toBe("/admin/data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle absolute paths with admin_basepath trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("data");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/data");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle settings path with admin_basepath trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["general"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/general");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle undefined basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
// When basepath is undefined, it defaults to empty string
|
||||||
|
expect(result).toBe("~/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null path segments", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["", "valid", ""]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/valid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-18
@@ -1,24 +1,9 @@
|
|||||||
import pkg from "./package.json" with { type: "json" };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
import { formatNumber } from "bknd/utils";
|
import { formatNumber } from "bknd/utils";
|
||||||
import * as esbuild from "esbuild";
|
|
||||||
|
|
||||||
if (process.env.DEBUG) {
|
const deps = Object.keys(pkg.dependencies);
|
||||||
await esbuild.build({
|
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||||
entryPoints: ["./src/cli/index.ts"],
|
|
||||||
outdir: "./dist/cli",
|
|
||||||
platform: "node",
|
|
||||||
minify: false,
|
|
||||||
format: "esm",
|
|
||||||
bundle: true,
|
|
||||||
external: ["jsonv-ts", "jsonv-ts/*"],
|
|
||||||
define: {
|
|
||||||
__isDev: "0",
|
|
||||||
__version: JSON.stringify(pkg.version),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/cli/index.ts"],
|
entrypoints: ["./src/cli/index.ts"],
|
||||||
@@ -26,7 +11,7 @@ const result = await Bun.build({
|
|||||||
outdir: "./dist/cli",
|
outdir: "./dist/cli",
|
||||||
env: "PUBLIC_*",
|
env: "PUBLIC_*",
|
||||||
minify: true,
|
minify: true,
|
||||||
external: ["jsonv-ts", "jsonv-ts/*"],
|
external,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
__version: JSON.stringify(pkg.version),
|
__version: JSON.stringify(pkg.version),
|
||||||
|
|||||||
+350
-234
@@ -2,6 +2,8 @@ import { $ } from "bun";
|
|||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import pkg from "./package.json" with { type: "json" };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
@@ -12,154 +14,178 @@ const clean = args.includes("--clean");
|
|||||||
|
|
||||||
// silence tsup
|
// silence tsup
|
||||||
const oldConsole = {
|
const oldConsole = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
};
|
};
|
||||||
console.log = () => {};
|
console.log = () => {};
|
||||||
console.warn = () => {};
|
console.warn = () => {};
|
||||||
|
|
||||||
const define = {
|
const define = {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
__version: JSON.stringify(pkg.version),
|
__version: JSON.stringify(pkg.version),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.info("Cleaning dist (w/o static)");
|
console.info("Cleaning dist (w/o static)");
|
||||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
// Cross-platform clean: remove all files/folders in dist except static
|
||||||
|
const distPath = join(import.meta.dir, "dist");
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(distPath);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === "static") continue;
|
||||||
|
const entryPath = join(distPath, entry);
|
||||||
|
rmSync(entryPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// dist may not exist yet, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let types_running = false;
|
let types_running = false;
|
||||||
function buildTypes() {
|
function buildTypes() {
|
||||||
if (types_running || !types) return;
|
if (types_running || !types) return;
|
||||||
types_running = true;
|
types_running = true;
|
||||||
|
|
||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types && !watch) {
|
if (types && !watch) {
|
||||||
buildTypes();
|
buildTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
let watcher_timeout: any;
|
let watcher_timeout: any;
|
||||||
function delayTypes() {
|
function delayTypes() {
|
||||||
if (!watch || !types) return;
|
if (!watch || !types) return;
|
||||||
if (watcher_timeout) {
|
if (watcher_timeout) {
|
||||||
clearTimeout(watcher_timeout);
|
clearTimeout(watcher_timeout);
|
||||||
}
|
}
|
||||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dependencies = Object.keys(pkg.dependencies);
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
const external = [
|
const external = [
|
||||||
"bun:test",
|
...dependencies,
|
||||||
"node:test",
|
"bun:test",
|
||||||
"node:assert/strict",
|
"node:test",
|
||||||
"@libsql/client",
|
"node:assert/strict",
|
||||||
"bknd",
|
"@libsql/client",
|
||||||
/^bknd\/.*/,
|
"bknd",
|
||||||
"jsonv-ts",
|
/^bknd\/.*/,
|
||||||
/^jsonv-ts\/.*/,
|
"jsonv-ts",
|
||||||
|
/^jsonv-ts\/.*/,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
async function buildApi() {
|
async function buildApi() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
// don't use tsup's broken watch, we'll handle it ourselves
|
||||||
define,
|
watch: false,
|
||||||
entry: ["src/index.ts", "src/core/utils/index.ts", "src/plugins/index.ts"],
|
define,
|
||||||
outDir: "dist",
|
entry: [
|
||||||
external: [...external],
|
"src/index.ts",
|
||||||
metafile: true,
|
"src/core/utils/index.ts",
|
||||||
platform: "browser",
|
"src/plugins/index.ts",
|
||||||
format: ["esm"],
|
"src/modes/index.ts",
|
||||||
splitting: false,
|
],
|
||||||
treeshake: true,
|
outDir: "dist",
|
||||||
loader: {
|
external: [...external],
|
||||||
".svg": "dataurl",
|
metafile: true,
|
||||||
},
|
target: "esnext",
|
||||||
onSuccess: async () => {
|
platform: "browser",
|
||||||
delayTypes();
|
removeNodeProtocol: false,
|
||||||
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
format: ["esm"],
|
||||||
},
|
splitting: false,
|
||||||
});
|
loader: {
|
||||||
|
".svg": "dataurl",
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
delayTypes();
|
||||||
|
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteClient(path: string) {
|
async function rewriteClient(path: string) {
|
||||||
const bundle = await Bun.file(path).text();
|
const bundle = await Bun.file(path).text();
|
||||||
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
await Bun.write(
|
||||||
|
path,
|
||||||
|
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI for direct imports
|
* Building UI for direct imports
|
||||||
*/
|
*/
|
||||||
async function buildUi() {
|
async function buildUi() {
|
||||||
const base = {
|
const base = {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
/codemirror/,
|
/codemirror/,
|
||||||
"@xyflow/react",
|
"@xyflow/react",
|
||||||
"@mantine/core",
|
"@mantine/core",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.logLevel = "silent";
|
options.logLevel = "silent";
|
||||||
},
|
},
|
||||||
} satisfies tsup.Options;
|
} satisfies tsup.Options;
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/index.js");
|
await rewriteClient("./dist/ui/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/client/index.ts"],
|
entry: ["src/ui/client/index.ts"],
|
||||||
outDir: "dist/ui/client",
|
outDir: "dist/ui/client",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/client/index.js");
|
await rewriteClient("./dist/ui/client/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,148 +194,238 @@ async function buildUi() {
|
|||||||
* - ui/client is external, and after built replaced with "bknd/client"
|
* - ui/client is external, and after built replaced with "bknd/client"
|
||||||
*/
|
*/
|
||||||
async function buildUiElements() {
|
async function buildUiElements() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
"ui/client",
|
"ui/client",
|
||||||
"react",
|
"bknd",
|
||||||
"react-dom",
|
/^bknd\/.*/,
|
||||||
"react/jsx-runtime",
|
"wouter",
|
||||||
"react/jsx-dev-runtime",
|
"react",
|
||||||
"use-sync-external-store",
|
"react-dom",
|
||||||
],
|
"react/jsx-runtime",
|
||||||
metafile: true,
|
"react/jsx-dev-runtime",
|
||||||
platform: "browser",
|
"use-sync-external-store",
|
||||||
format: ["esm"],
|
],
|
||||||
splitting: false,
|
metafile: true,
|
||||||
bundle: true,
|
platform: "browser",
|
||||||
treeshake: true,
|
format: ["esm"],
|
||||||
loader: {
|
splitting: false,
|
||||||
".svg": "dataurl",
|
bundle: true,
|
||||||
},
|
treeshake: true,
|
||||||
esbuildOptions: (options) => {
|
loader: {
|
||||||
options.alias = {
|
".svg": "dataurl",
|
||||||
// not important for elements, mock to reduce bundle
|
},
|
||||||
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
esbuildOptions: (options) => {
|
||||||
};
|
options.alias = {
|
||||||
},
|
// not important for elements, mock to reduce bundle
|
||||||
onSuccess: async () => {
|
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
||||||
await rewriteClient("./dist/ui/elements/index.js");
|
};
|
||||||
delayTypes();
|
},
|
||||||
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
onSuccess: async () => {
|
||||||
},
|
await rewriteClient("./dist/ui/elements/index.js");
|
||||||
});
|
delayTypes();
|
||||||
|
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
function baseConfig(
|
||||||
return {
|
adapter: string,
|
||||||
minify,
|
overrides: Partial<tsup.Options> = {},
|
||||||
sourcemap,
|
): tsup.Options {
|
||||||
watch,
|
return {
|
||||||
entry: [`src/adapter/${adapter}/index.ts`],
|
minify,
|
||||||
format: ["esm"],
|
sourcemap,
|
||||||
platform: "neutral",
|
watch: false,
|
||||||
outDir: `dist/adapter/${adapter}`,
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
metafile: true,
|
format: ["esm"],
|
||||||
splitting: false,
|
platform: "neutral",
|
||||||
onSuccess: async () => {
|
outDir: `dist/adapter/${adapter}`,
|
||||||
delayTypes();
|
metafile: true,
|
||||||
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
splitting: false,
|
||||||
},
|
removeNodeProtocol: false,
|
||||||
...overrides,
|
onSuccess: async () => {
|
||||||
define: {
|
delayTypes();
|
||||||
...define,
|
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
||||||
...overrides.define,
|
},
|
||||||
},
|
...overrides,
|
||||||
external: [
|
define: {
|
||||||
/^cloudflare*/,
|
...define,
|
||||||
/^@?hono.*?/,
|
...overrides.define,
|
||||||
/^(bknd|react|next|node).*?/,
|
},
|
||||||
/.*\.(html)$/,
|
external: [
|
||||||
...external,
|
/^cloudflare*/,
|
||||||
...(Array.isArray(overrides.external) ? overrides.external : []),
|
/^@?hono.*?/,
|
||||||
],
|
/^(bknd|react|next|node).*?/,
|
||||||
};
|
/.*\.(html)$/,
|
||||||
|
...external,
|
||||||
|
...(Array.isArray(overrides.external) ? overrides.external : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAdapters() {
|
async function buildAdapters() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// base adapter handles
|
// base adapter handles
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig(""),
|
...baseConfig(""),
|
||||||
entry: ["src/adapter/index.ts"],
|
target: "esnext",
|
||||||
outDir: "dist/adapter",
|
platform: "neutral",
|
||||||
}),
|
entry: ["src/adapter/index.ts"],
|
||||||
|
outDir: "dist/adapter",
|
||||||
|
// only way to keep @vite-ignore comments
|
||||||
|
minify: false,
|
||||||
|
}),
|
||||||
|
|
||||||
// specific adatpers
|
// specific adatpers
|
||||||
tsup.build(baseConfig("react-router")),
|
tsup.build(baseConfig("react-router")),
|
||||||
tsup.build(
|
tsup.build(
|
||||||
baseConfig("bun", {
|
baseConfig("browser", {
|
||||||
external: [/^bun\:.*/],
|
external: [/^sqlocal\/?.*?/, "wouter"],
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(baseConfig("astro")),
|
|
||||||
tsup.build(baseConfig("aws")),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("cloudflare", {
|
|
||||||
external: ["wrangler", "node:process"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("cloudflare/proxy", {
|
|
||||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
|
||||||
outDir: "dist/adapter/cloudflare",
|
|
||||||
metafile: false,
|
|
||||||
external: [/bknd/, "wrangler", "node:process"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("vite"),
|
|
||||||
platform: "node",
|
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(
|
||||||
...baseConfig("nextjs"),
|
baseConfig("bun", {
|
||||||
platform: "node",
|
external: [/^bun\:.*/],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(baseConfig("astro")),
|
||||||
...baseConfig("node"),
|
tsup.build(baseConfig("aws")),
|
||||||
platform: "node",
|
tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: ["wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
tsup.build({
|
tsup.build(
|
||||||
...baseConfig("sqlite/edge"),
|
baseConfig("cloudflare/proxy", {
|
||||||
entry: ["src/adapter/sqlite/edge.ts"],
|
target: "esnext",
|
||||||
outDir: "dist/adapter/sqlite",
|
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||||
metafile: false,
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/node"),
|
...baseConfig("vite"),
|
||||||
entry: ["src/adapter/sqlite/node.ts"],
|
platform: "node",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
platform: "node",
|
|
||||||
metafile: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sqlite/bun"),
|
...baseConfig("nextjs"),
|
||||||
entry: ["src/adapter/sqlite/bun.ts"],
|
platform: "node",
|
||||||
outDir: "dist/adapter/sqlite",
|
}),
|
||||||
metafile: false,
|
|
||||||
external: [/^bun\:.*/],
|
tsup.build({
|
||||||
}),
|
...baseConfig("tanstack-start"),
|
||||||
]);
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sveltekit"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("nuxt"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("node"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/edge"),
|
||||||
|
entry: ["src/adapter/sqlite/edge.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/node"),
|
||||||
|
entry: ["src/adapter/sqlite/node.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
platform: "node",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("sqlite/bun"),
|
||||||
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
external: [/^bun\:.*/],
|
||||||
|
}),
|
||||||
|
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
async function buildAll() {
|
||||||
|
await Promise.all([
|
||||||
|
buildApi(),
|
||||||
|
buildUi(),
|
||||||
|
buildUiElements(),
|
||||||
|
buildAdapters(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// initial build
|
||||||
|
await buildAll();
|
||||||
|
|
||||||
|
// custom watcher since tsup's watch is broken in 8.3.5+
|
||||||
|
if (watch) {
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let isBuilding = false;
|
||||||
|
|
||||||
|
const rebuild = async () => {
|
||||||
|
if (isBuilding) return;
|
||||||
|
isBuilding = true;
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
||||||
|
try {
|
||||||
|
await buildAll();
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
||||||
|
} catch (e) {
|
||||||
|
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
||||||
|
}
|
||||||
|
isBuilding = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedRebuild = () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(rebuild, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// watch src directory recursively
|
||||||
|
fsWatch(
|
||||||
|
join(import.meta.dir, "src"),
|
||||||
|
{ recursive: true },
|
||||||
|
(event, filename) => {
|
||||||
|
if (!filename) return;
|
||||||
|
// ignore non-source files
|
||||||
|
if (
|
||||||
|
!filename.endsWith(".ts") &&
|
||||||
|
!filename.endsWith(".tsx") &&
|
||||||
|
!filename.endsWith(".css")
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||||
|
debouncedRebuild();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// keep process alive
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[install]
|
|
||||||
#registry = "http://localhost:4873"
|
|
||||||
|
|
||||||
[test]
|
|
||||||
coverageSkipTestFiles = true
|
|
||||||
console.depth = 10
|
|
||||||
+7
-6
@@ -3,10 +3,11 @@ import path from "node:path";
|
|||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
|
||||||
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
|
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
cmd: string[] | string,
|
cmd: string[] | string,
|
||||||
opts: Bun.SpawnOptions.OptionsObject & {},
|
opts: RunOptions,
|
||||||
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
||||||
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -30,7 +31,7 @@ async function run(
|
|||||||
|
|
||||||
const text = decoder.decode(value);
|
const text = decoder.decode(value);
|
||||||
if (!resolveCalled) {
|
if (!resolveCalled) {
|
||||||
console.log(c.dim(text.replace(/\n$/, "")));
|
console.info(c.dim(text.replace(/\n$/, "")));
|
||||||
}
|
}
|
||||||
onChunk(
|
onChunk(
|
||||||
text,
|
text,
|
||||||
@@ -189,21 +190,21 @@ const adapters = {
|
|||||||
|
|
||||||
async function testAdapter(name: keyof typeof adapters) {
|
async function testAdapter(name: keyof typeof adapters) {
|
||||||
const config = adapters[name];
|
const config = adapters[name];
|
||||||
console.log("adapter", c.cyan(name));
|
console.info("adapter", c.cyan(name));
|
||||||
await config.clean();
|
await config.clean();
|
||||||
|
|
||||||
const { proc, data } = await config.start();
|
const { proc, data } = await config.start();
|
||||||
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
console.info("proc:", proc.pid, "data:", c.cyan(data));
|
||||||
//proc.kill();process.exit(0);
|
//proc.kill();process.exit(0);
|
||||||
|
|
||||||
const add_env = "env" in config && config.env ? config.env : "";
|
const add_env = "env" in config && config.env ? config.env : "";
|
||||||
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
||||||
console.log("DONE!");
|
console.info("DONE!");
|
||||||
|
|
||||||
while (!proc.killed) {
|
while (!proc.killed) {
|
||||||
proc.kill("SIGINT");
|
proc.kill("SIGINT");
|
||||||
await Bun.sleep(250);
|
await Bun.sleep(250);
|
||||||
console.log("Waiting for process to exit...");
|
console.info("Waiting for process to exit...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+39
-33
@@ -1,44 +1,50 @@
|
|||||||
const adapter = process.env.TEST_ADAPTER;
|
const adapter = process.env.TEST_ADAPTER;
|
||||||
|
|
||||||
const default_config = {
|
const default_config = {
|
||||||
media_adapter: "local",
|
media_adapter: "local",
|
||||||
base_path: "",
|
base_path: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const configs = {
|
const configs = {
|
||||||
cloudflare: {
|
cloudflare: {
|
||||||
media_adapter: "r2",
|
media_adapter: "r2",
|
||||||
},
|
},
|
||||||
"react-router": {
|
"react-router": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
nextjs: {
|
nextjs: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
astro: {
|
nuxt: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
node: {
|
astro: {
|
||||||
base_path: "",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
bun: {
|
node: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
|
bun: {
|
||||||
|
base_path: "",
|
||||||
|
},
|
||||||
|
"tanstack-start": {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAdapterConfig(): typeof default_config {
|
export function getAdapterConfig(): typeof default_config {
|
||||||
if (adapter) {
|
if (adapter) {
|
||||||
if (!configs[adapter]) {
|
if (!configs[adapter]) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...default_config,
|
...default_config,
|
||||||
...configs[adapter],
|
...configs[adapter],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return default_config;
|
return default_config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { createApp } from "bknd/adapter/bun";
|
|||||||
async function generate() {
|
async function generate() {
|
||||||
console.info("Generating MCP documentation...");
|
console.info("Generating MCP documentation...");
|
||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
initialConfig: {
|
connection: {
|
||||||
|
url: ":memory:",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
server: {
|
server: {
|
||||||
mcp: {
|
mcp: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
path: "/mcp2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
@@ -24,9 +28,9 @@ async function generate() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await app.build();
|
await app.build();
|
||||||
|
await app.getMcpClient().ping();
|
||||||
|
|
||||||
const res = await app.server.request("/mcp?explain=1");
|
const { tools, resources } = app.mcp!.toJSON();
|
||||||
const { tools, resources } = await res.json();
|
|
||||||
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
|
await Bun.write("../docs/mcp.json", JSON.stringify({ tools, resources }, null, 2));
|
||||||
|
|
||||||
console.info("MCP documentation generated.");
|
console.info("MCP documentation generated.");
|
||||||
|
|||||||
+96
-53
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.17.1",
|
"version": "0.21.0-rc.1",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/bknd-io/bknd/issues"
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.2.19",
|
"packageManager": "bun@1.3.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22.13"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
||||||
"updater": "bun x npm-check-updates -ui",
|
"updater": "bun x npm-check-updates -ui",
|
||||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||||
"prepublishOnly": "bun run types && bun run test && bun run test:node && 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",
|
"postpublish": "rm -f README.md",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
"test:all": "bun run test && bun run test:node",
|
"test:all": "bun run test && bun run test:node",
|
||||||
@@ -39,101 +39,113 @@
|
|||||||
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
||||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||||
"test:vitest:coverage": "vitest run --coverage",
|
"test:vitest:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "VITE_DB_URL=:memory: playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:adapters": "VITE_DB_URL=:memory: 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:ui": "VITE_DB_URL=:memory: playwright test --ui",
|
||||||
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
"test:e2e:debug": "VITE_DB_URL=:memory: playwright test --debug",
|
||||||
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
"test:e2e:report": "VITE_DB_URL=:memory: playwright show-report",
|
||||||
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
"docs:build-assets": "bun internal/docs.build-assets.ts"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.11",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@hono/swagger-ui": "^0.5.1",
|
"@hono/swagger-ui": "^0.5.2",
|
||||||
"@mantine/core": "^7.17.1",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.1",
|
"@mantine/hooks": "^7.17.1",
|
||||||
"@tanstack/react-form": "^1.0.5",
|
"@tanstack/react-form": "^1.0.5",
|
||||||
"@uiw/react-codemirror": "^4.23.10",
|
"@uiw/react-codemirror": "^4.25.2",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.9.2",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.3",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.20",
|
||||||
"fast-xml-parser": "^5.0.8",
|
"fast-xml-parser": "^5.3.1",
|
||||||
"hono": "4.8.3",
|
"hono": "4.10.4",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "0.8.2",
|
"jsonv-ts": "^0.10.1",
|
||||||
"kysely": "0.27.6",
|
"kysely": "0.28.8",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
"swr": "^2.3.3"
|
"swr": "^2.3.6",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
|
"zustand": "^4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.922.0",
|
||||||
"@bluwy/giget-core": "^0.1.2",
|
"@bluwy/giget-core": "^0.1.6",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@cloudflare/vitest-pool-workers": "^0.8.38",
|
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||||
"@cloudflare/workers-types": "^4.20250606.0",
|
"@cloudflare/workers-types": "^4.20251014.0",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hono/vite-dev-server": "^0.19.1",
|
"@hono/vite-dev-server": "^0.23.0",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@libsql/client": "^0.15.9",
|
"@libsql/client": "^0.15.15",
|
||||||
"@mantine/modals": "^7.17.1",
|
"@mantine/modals": "^7.17.1",
|
||||||
"@mantine/notifications": "^7.17.1",
|
"@mantine/notifications": "^7.17.1",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@rjsf/core": "5.22.2",
|
"@rjsf/core": "5.22.2",
|
||||||
|
"@rjsf/utils": "5.22.0",
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@tabler/icons-react": "3.35.0",
|
||||||
"@tailwindcss/postcss": "^4.0.12",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@tanstack/react-store": "^0.8.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@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": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
"@vitest/coverage-v8": "^3.0.9",
|
"@vitest/coverage-v8": "3.0.9",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"commander": "^14.0.2",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.1.0",
|
||||||
"kysely-d1": "^0.3.0",
|
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
|
"kysely-postgres-js": "^2.0.0",
|
||||||
|
"libsql": "^0.5.22",
|
||||||
"libsql-stateless-easy": "^1.8.0",
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
"open": "^10.1.0",
|
"miniflare": "^4.20251011.2",
|
||||||
|
"open": "^10.2.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"picocolors": "^1.1.1",
|
"pg": "^8.16.3",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"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": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.66.0",
|
||||||
"react-icons": "5.2.1",
|
"react-icons": "5.5.0",
|
||||||
"react-json-view-lite": "^2.4.1",
|
"react-json-view-lite": "^2.5.0",
|
||||||
"sql-formatter": "^15.4.11",
|
"sql-formatter": "^15.6.10",
|
||||||
|
"sqlocal": "^0.16.0",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.0.12",
|
"tailwindcss": "^4.1.16",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsc-alias": "^1.8.11",
|
"tsc-alias": "^1.8.16",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.5.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.20.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^13.0.0",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.12",
|
||||||
"vite-plugin-circular-dependency": "^0.5.0",
|
"vite-plugin-circular-dependency": "^0.5.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.9",
|
"vitest": "3.0.9",
|
||||||
"wouter": "^3.6.0"
|
"wouter": "^3.7.1",
|
||||||
|
"wrangler": "^4.45.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.14.3"
|
"@hono/node-server": "^1.19.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=19",
|
"react": ">=19",
|
||||||
@@ -178,6 +190,11 @@
|
|||||||
"import": "./dist/plugins/index.js",
|
"import": "./dist/plugins/index.js",
|
||||||
"require": "./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": {
|
"./adapter/sqlite": {
|
||||||
"types": "./dist/types/adapter/sqlite/edge.d.ts",
|
"types": "./dist/types/adapter/sqlite/edge.d.ts",
|
||||||
"import": {
|
"import": {
|
||||||
@@ -216,6 +233,11 @@
|
|||||||
"import": "./dist/adapter/nextjs/index.js",
|
"import": "./dist/adapter/nextjs/index.js",
|
||||||
"require": "./dist/adapter/nextjs/index.js"
|
"require": "./dist/adapter/nextjs/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/nuxt": {
|
||||||
|
"types": "./dist/types/adapter/nuxt/index.d.ts",
|
||||||
|
"import": "./dist/adapter/nuxt/index.js",
|
||||||
|
"require": "./dist/adapter/nuxt/index.js"
|
||||||
|
},
|
||||||
"./adapter/react-router": {
|
"./adapter/react-router": {
|
||||||
"types": "./dist/types/adapter/react-router/index.d.ts",
|
"types": "./dist/types/adapter/react-router/index.d.ts",
|
||||||
"import": "./dist/adapter/react-router/index.js",
|
"import": "./dist/adapter/react-router/index.js",
|
||||||
@@ -236,11 +258,26 @@
|
|||||||
"import": "./dist/adapter/astro/index.js",
|
"import": "./dist/adapter/astro/index.js",
|
||||||
"require": "./dist/adapter/astro/index.js"
|
"require": "./dist/adapter/astro/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/sveltekit": {
|
||||||
|
"types": "./dist/types/adapter/sveltekit/index.d.ts",
|
||||||
|
"import": "./dist/adapter/sveltekit/index.js",
|
||||||
|
"require": "./dist/adapter/sveltekit/index.js"
|
||||||
|
},
|
||||||
"./adapter/aws": {
|
"./adapter/aws": {
|
||||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||||
"import": "./dist/adapter/aws/index.js",
|
"import": "./dist/adapter/aws/index.js",
|
||||||
"require": "./dist/adapter/aws/index.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"
|
||||||
|
},
|
||||||
|
"./adapter/tanstack-start": {
|
||||||
|
"types": "./dist/types/adapter/tanstack-start/index.d.ts",
|
||||||
|
"import": "./dist/adapter/tanstack-start/index.js",
|
||||||
|
"require": "./dist/adapter/tanstack-start/index.js"
|
||||||
|
},
|
||||||
"./dist/main.css": "./dist/ui/main.css",
|
"./dist/main.css": "./dist/ui/main.css",
|
||||||
"./dist/styles.css": "./dist/ui/styles.css",
|
"./dist/styles.css": "./dist/ui/styles.css",
|
||||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
||||||
@@ -255,9 +292,12 @@
|
|||||||
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
||||||
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
||||||
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
||||||
|
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
|
||||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||||
|
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||||
|
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
|
||||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -284,9 +324,12 @@
|
|||||||
"serverless",
|
"serverless",
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
|
"nuxt",
|
||||||
"remix",
|
"remix",
|
||||||
"react-router",
|
"react-router",
|
||||||
"astro",
|
"astro",
|
||||||
|
"sveltekit",
|
||||||
|
"svelte",
|
||||||
"bun",
|
"bun",
|
||||||
"node"
|
"node"
|
||||||
]
|
]
|
||||||
|
|||||||
+49
-35
@@ -40,10 +40,11 @@ export type ApiOptions = {
|
|||||||
data?: SubApiOptions<DataApiOptions>;
|
data?: SubApiOptions<DataApiOptions>;
|
||||||
auth?: SubApiOptions<AuthApiOptions>;
|
auth?: SubApiOptions<AuthApiOptions>;
|
||||||
media?: SubApiOptions<MediaApiOptions>;
|
media?: SubApiOptions<MediaApiOptions>;
|
||||||
|
credentials?: RequestCredentials;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
token?: string;
|
token?: string;
|
||||||
user?: TApiUser;
|
user?: TApiUser | null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
request: Request;
|
request: Request;
|
||||||
@@ -60,14 +61,14 @@ export class Api {
|
|||||||
private token?: string;
|
private token?: string;
|
||||||
private user?: TApiUser;
|
private user?: TApiUser;
|
||||||
private verified = false;
|
private verified = false;
|
||||||
private token_transport: "header" | "cookie" | "none" = "header";
|
public token_transport: "header" | "cookie" | "none" = "header";
|
||||||
|
|
||||||
public system!: SystemApi;
|
public system!: SystemApi;
|
||||||
public data!: DataApi;
|
public data!: DataApi;
|
||||||
public auth!: AuthApi;
|
public auth!: AuthApi;
|
||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private options: ApiOptions = {}) {
|
constructor(public options: ApiOptions = {}) {
|
||||||
// only mark verified if forced
|
// only mark verified if forced
|
||||||
this.verified = options.verified === true;
|
this.verified = options.verified === true;
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// try authorization header
|
// try authorization header
|
||||||
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
const headerToken = this.options.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||||
if (headerToken) {
|
if (headerToken) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
@@ -129,29 +130,45 @@ export class Api {
|
|||||||
} else if (this.storage) {
|
} else if (this.storage) {
|
||||||
this.storage.getItem(this.tokenKey).then((token) => {
|
this.storage.getItem(this.tokenKey).then((token) => {
|
||||||
this.token_transport = "header";
|
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() {
|
private get storage() {
|
||||||
if (!this.options.storage) return null;
|
const storage = this.options.storage;
|
||||||
return {
|
return new Proxy(
|
||||||
getItem: async (key: string) => {
|
{},
|
||||||
return await this.options.storage!.getItem(key);
|
{
|
||||||
|
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) => {
|
) as any;
|
||||||
return await this.options.storage!.setItem(key, value);
|
|
||||||
},
|
|
||||||
removeItem: async (key: string) => {
|
|
||||||
return await this.options.storage!.removeItem(key);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
updateToken(
|
||||||
|
token?: string,
|
||||||
|
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
|
||||||
|
) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.verified = false;
|
this.verified = opts?.verified === true;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||||
@@ -159,21 +176,22 @@ export class Api {
|
|||||||
this.user = undefined;
|
this.user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
if (opts?.trigger !== false) {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
|
}
|
||||||
|
};
|
||||||
if (this.storage) {
|
if (this.storage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
this.storage.setItem(key, token).then(() => {
|
this.storage.setItem(key, token).then(emit);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.storage.removeItem(key).then(() => {
|
this.storage.removeItem(key).then(emit);
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (opts?.trigger !== false) {
|
if (opts?.trigger !== false) {
|
||||||
this.options.onAuthStateChange?.(this.getAuthState());
|
emit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +200,7 @@ export class Api {
|
|||||||
|
|
||||||
private markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
this.verified = verfied;
|
this.verified = verfied;
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +227,6 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async verifyAuth() {
|
async verifyAuth() {
|
||||||
if (!this.token) {
|
|
||||||
this.markAuthVerified(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { ok, data } = await this.auth.me();
|
const { ok, data } = await this.auth.me();
|
||||||
const user = data?.user;
|
const user = data?.user;
|
||||||
@@ -221,10 +235,10 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.markAuthVerified(true);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.markAuthVerified(false);
|
|
||||||
this.updateToken(undefined);
|
this.updateToken(undefined);
|
||||||
|
} finally {
|
||||||
|
this.markAuthVerified(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +253,7 @@ export class Api {
|
|||||||
headers: this.options.headers,
|
headers: this.options.headers,
|
||||||
token_transport: this.token_transport,
|
token_transport: this.token_transport,
|
||||||
verbose: this.options.verbose,
|
verbose: this.options.verbose,
|
||||||
|
credentials: this.options.credentials,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,10 +272,9 @@ export class Api {
|
|||||||
this.auth = new AuthApi(
|
this.auth = new AuthApi(
|
||||||
{
|
{
|
||||||
...baseParams,
|
...baseParams,
|
||||||
credentials: this.options.storage ? "omit" : "include",
|
|
||||||
...this.options.auth,
|
...this.options.auth,
|
||||||
onTokenUpdate: (token) => {
|
onTokenUpdate: (token, verified) => {
|
||||||
this.updateToken(token, { rebuild: true });
|
this.updateToken(token, { rebuild: true, verified, trigger: true });
|
||||||
this.options.auth?.onTokenUpdate?.(token);
|
this.options.auth?.onTokenUpdate?.(token);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+45
-26
@@ -5,17 +5,17 @@ import type { em as prototypeEm } from "data/prototype";
|
|||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
ModuleManager,
|
|
||||||
type InitialModuleConfigs,
|
|
||||||
type ModuleBuildContext,
|
|
||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
type ModuleManagerOptions,
|
|
||||||
type Modules,
|
type Modules,
|
||||||
|
ModuleManager,
|
||||||
|
type ModuleBuildContext,
|
||||||
|
type ModuleManagerOptions,
|
||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
|
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
import type { MaybePromise } from "core/types";
|
import type { MaybePromise, PartialRec } from "core/types";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
||||||
|
|
||||||
@@ -93,20 +93,23 @@ export type AppOptions = {
|
|||||||
email?: IEmailDriver;
|
email?: IEmailDriver;
|
||||||
cache?: ICacheDriver;
|
cache?: ICacheDriver;
|
||||||
};
|
};
|
||||||
|
mode?: "db" | "code";
|
||||||
|
readonly?: boolean;
|
||||||
};
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
/**
|
|
||||||
* bla
|
|
||||||
*/
|
|
||||||
connection?: Connection | { url: string };
|
connection?: Connection | { url: string };
|
||||||
initialConfig?: InitialModuleConfigs;
|
config?: PartialRec<ModuleConfigs>;
|
||||||
options?: AppOptions;
|
options?: AppOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = InitialModuleConfigs;
|
export type AppConfig = { version: number } & ModuleConfigs;
|
||||||
export type LocalApiOptions = Request | ApiOptions;
|
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;
|
static readonly Events = AppEvents;
|
||||||
|
|
||||||
modules: ModuleManager;
|
modules: ModuleManager;
|
||||||
@@ -121,8 +124,8 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
public connection: C,
|
public connection: C,
|
||||||
_initialConfig?: InitialModuleConfigs,
|
_config?: Config,
|
||||||
private options?: Options,
|
public options?: Options,
|
||||||
) {
|
) {
|
||||||
this.drivers = options?.drivers ?? {};
|
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.plugins.set(config.name, config);
|
||||||
}
|
}
|
||||||
this.runPlugins("onBoot");
|
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 ?? {}),
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _config,
|
||||||
onUpdated: this.onUpdated.bind(this),
|
onUpdated: this.onUpdated.bind(this),
|
||||||
onFirstBoot: this.onFirstBoot.bind(this),
|
onFirstBoot: this.onFirstBoot.bind(this),
|
||||||
onServerInit: this.onServerInit.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);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get mode() {
|
||||||
|
return this.options?.mode ?? "db";
|
||||||
|
}
|
||||||
|
|
||||||
|
isReadOnly() {
|
||||||
|
return Boolean(this.mode === "code" || this.options?.readonly);
|
||||||
|
}
|
||||||
|
|
||||||
get emgr() {
|
get emgr() {
|
||||||
return this.modules.ctx().emgr;
|
return this.modules.ctx().emgr;
|
||||||
}
|
}
|
||||||
@@ -175,7 +190,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
return results as any;
|
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
|
// prevent multiple concurrent builds
|
||||||
if (this._building) {
|
if (this._building) {
|
||||||
while (this._building) {
|
while (this._building) {
|
||||||
@@ -188,7 +203,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this._building = true;
|
this._building = true;
|
||||||
|
|
||||||
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||||
await this.modules.build({ fetch: options?.fetch });
|
await this.modules.build();
|
||||||
|
|
||||||
const { guard } = this.modules.ctx();
|
const { guard } = this.modules.ctx();
|
||||||
|
|
||||||
@@ -215,10 +230,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
this._building = false;
|
this._building = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
|
||||||
return this.modules.mutateConfigSafe(module);
|
|
||||||
}
|
|
||||||
|
|
||||||
get server() {
|
get server() {
|
||||||
return this.modules.server;
|
return this.modules.server;
|
||||||
}
|
}
|
||||||
@@ -232,6 +243,9 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
}
|
}
|
||||||
|
|
||||||
get fetch(): Hono["fetch"] {
|
get fetch(): Hono["fetch"] {
|
||||||
|
if (!this.isBuilt()) {
|
||||||
|
console.error("App is not built yet, run build() first");
|
||||||
|
}
|
||||||
return this.server.fetch as any;
|
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);
|
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) {
|
getApi(options?: LocalApiOptions) {
|
||||||
const fetcher = this.server.request as typeof fetch;
|
const fetcher = this.server.request as typeof fetch;
|
||||||
if (options && options instanceof Request) {
|
if (options && options instanceof Request) {
|
||||||
@@ -290,13 +305,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
}
|
}
|
||||||
|
|
||||||
getMcpClient() {
|
getMcpClient() {
|
||||||
if (!this.mcp) {
|
const config = this.modules.get("server").config.mcp;
|
||||||
|
if (!config.enabled) {
|
||||||
throw new Error("MCP is not 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({
|
return new McpClient({
|
||||||
url: "http://localhost" + mcpPath,
|
url,
|
||||||
fetch: this.server.request,
|
fetch: this.server.request,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -364,11 +380,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
|
|||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
for (const { name, result } of results) {
|
for (const { name, result } of results) {
|
||||||
if (result) {
|
if (result) {
|
||||||
$console.log(`[Plugin:${name}] schema`);
|
|
||||||
ctx.helper.ensureSchema(result);
|
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");
|
throw new Error("Invalid connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new App(config.connection, config.initialConfig, config.options);
|
return new App(config.connection, config.config, config.options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TestRunner } from "core/test";
|
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 type { App } from "App";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
export function adapterTestSuite<
|
export function adapterTestSuite<
|
||||||
Config extends BkndConfig = BkndConfig,
|
Config extends BkndConfig = BkndConfig,
|
||||||
@@ -13,24 +14,17 @@ export function adapterTestSuite<
|
|||||||
label = "app",
|
label = "app",
|
||||||
overrides = {},
|
overrides = {},
|
||||||
}: {
|
}: {
|
||||||
makeApp: (
|
makeApp: (config: Config, args?: Args) => Promise<App>;
|
||||||
config: Config,
|
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
|
||||||
args?: Args,
|
|
||||||
opts?: RuntimeOptions | FrameworkOptions,
|
|
||||||
) => Promise<App>;
|
|
||||||
makeHandler?: (
|
|
||||||
config?: Config,
|
|
||||||
args?: Args,
|
|
||||||
opts?: RuntimeOptions | FrameworkOptions,
|
|
||||||
) => (request: Request) => Promise<Response>;
|
|
||||||
label?: string;
|
label?: string;
|
||||||
overrides?: {
|
overrides?: {
|
||||||
dbUrl?: string;
|
dbUrl?: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { test, expect, mock } = testRunner;
|
const { test, expect, mock, beforeAll, afterAll } = testRunner;
|
||||||
const id = crypto.randomUUID();
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(() => enableConsoleLog());
|
||||||
|
|
||||||
test(`creates ${label}`, async () => {
|
test(`creates ${label}`, async () => {
|
||||||
const beforeBuild = mock(async () => null) as any;
|
const beforeBuild = mock(async () => null) as any;
|
||||||
@@ -39,7 +33,7 @@ export function adapterTestSuite<
|
|||||||
const config = {
|
const config = {
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
connection: { url: env.url },
|
connection: { url: env.url },
|
||||||
initialConfig: {
|
config: {
|
||||||
server: { cors: { origin: env.origin } },
|
server: { cors: { origin: env.origin } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -53,11 +47,10 @@ export function adapterTestSuite<
|
|||||||
url: overrides.dbUrl ?? ":memory:",
|
url: overrides.dbUrl ?? ":memory:",
|
||||||
origin: "localhost",
|
origin: "localhost",
|
||||||
} as any,
|
} as any,
|
||||||
{ force: false, id },
|
|
||||||
);
|
);
|
||||||
expect(app).toBeDefined();
|
expect(app).toBeDefined();
|
||||||
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
||||||
expect(beforeBuild).toHaveBeenCalledTimes(1);
|
expect(beforeBuild).toHaveBeenCalledTimes(2);
|
||||||
expect(onBuilt).toHaveBeenCalledTimes(1);
|
expect(onBuilt).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,8 +61,8 @@ export function adapterTestSuite<
|
|||||||
return { res, data };
|
return { res, data };
|
||||||
};
|
};
|
||||||
|
|
||||||
test("responds with the same app id", async () => {
|
/* test.skip("responds with the same app id", async () => {
|
||||||
const fetcher = makeHandler(undefined, undefined, { force: false, id });
|
const fetcher = makeHandler(undefined, undefined, { id });
|
||||||
|
|
||||||
const { res, data } = await getConfig(fetcher);
|
const { res, data } = await getConfig(fetcher);
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
@@ -77,14 +70,14 @@ export function adapterTestSuite<
|
|||||||
expect(data.server.cors.origin).toEqual("localhost");
|
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
|
// 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);
|
const { res, data } = await getConfig(fetcher);
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(data.server.cors.origin).toEqual("*");
|
expect(data.server.cors.origin).toEqual("*");
|
||||||
});
|
}); */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
|
|||||||
describe("astro adapter", () => {
|
describe("astro adapter", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: astro.getApp,
|
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 }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 AstroEnv = NodeJS.ProcessEnv;
|
||||||
type TAstro = {
|
type TAstro = {
|
||||||
@@ -8,18 +8,16 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
|||||||
|
|
||||||
export async function getApp<Env = AstroEnv>(
|
export async function getApp<Env = AstroEnv>(
|
||||||
config: AstroBkndConfig<Env> = {},
|
config: AstroBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = import.meta.env as Env,
|
||||||
opts: FrameworkOptions = {},
|
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args ?? import.meta.env, opts);
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<Env = AstroEnv>(
|
export function serve<Env = AstroEnv>(
|
||||||
config: AstroBkndConfig<Env> = {},
|
config: AstroBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = import.meta.env as Env,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
) {
|
||||||
return async (fnArgs: TAstro) => {
|
return async (fnArgs: TAstro) => {
|
||||||
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
return (await getApp(config, args)).fetch(fnArgs.request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { handle } from "hono/aws-lambda";
|
import { handle } from "hono/aws-lambda";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
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;
|
type AwsLambdaEnv = object;
|
||||||
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
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>(
|
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||||
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
let additional: Partial<RuntimeBkndConfig> = {
|
let additional: Partial<RuntimeBkndConfig> = {
|
||||||
adminOptions,
|
adminOptions,
|
||||||
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
|||||||
...additional,
|
...additional,
|
||||||
},
|
},
|
||||||
args ?? process.env,
|
args ?? process.env,
|
||||||
opts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||||
config: AwsLambdaBkndConfig<Env> = {},
|
config: AwsLambdaBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
return async (event) => {
|
return async (event) => {
|
||||||
const app = await createApp(config, args, opts);
|
const app = await createApp(config, args);
|
||||||
return await handle(app.server)(event);
|
return await handle(app.server)(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ describe("aws adapter", () => {
|
|||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: awsLambda.createApp,
|
makeApp: awsLambda.createApp,
|
||||||
// @todo: add a request to lambda event translator?
|
// @todo: add a request to lambda event translator?
|
||||||
makeHandler: (c, a, o) => async (request: Request) => {
|
makeHandler: (c, a) => async (request: Request) => {
|
||||||
const app = await awsLambda.createApp(c, a, o);
|
const app = await awsLambda.createApp(c, a);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./OpfsStorageAdapter";
|
||||||
|
export * from "./BkndBrowserApp";
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
/// <reference types="bun-types" />
|
|
||||||
|
|
||||||
import path from "node:path";
|
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 { registerLocalMediaAdapter } from ".";
|
||||||
import { config, type App } from "bknd";
|
import { config, type App } from "bknd";
|
||||||
import type { ServeOptions } from "bun";
|
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
|
||||||
type BunEnv = Bun.Env;
|
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>(
|
export async function createApp<Env = BunEnv>(
|
||||||
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = Bun.env as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
registerLocalMediaAdapter();
|
registerLocalMediaAdapter();
|
||||||
@@ -27,20 +24,18 @@ export async function createApp<Env = BunEnv>(
|
|||||||
}),
|
}),
|
||||||
...config,
|
...config,
|
||||||
},
|
},
|
||||||
args ?? (process.env as Env),
|
args,
|
||||||
opts,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHandler<Env = BunEnv>(
|
export function createHandler<Env = BunEnv>(
|
||||||
config: BunBkndConfig<Env> = {},
|
config: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = Bun.env as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
let app: App | undefined;
|
let app: App | undefined;
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = await createApp(config, args ?? (process.env as Env), opts);
|
app = await createApp(config, args);
|
||||||
}
|
}
|
||||||
return app.fetch(req);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
@@ -48,36 +43,38 @@ export function createHandler<Env = BunEnv>(
|
|||||||
|
|
||||||
export function serve<Env = BunEnv>(
|
export function serve<Env = BunEnv>(
|
||||||
{
|
{
|
||||||
|
app,
|
||||||
distPath,
|
distPath,
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
config: _config,
|
||||||
options,
|
options,
|
||||||
port = config.server.default_port,
|
port = config.server.default_port,
|
||||||
onBuilt,
|
onBuilt,
|
||||||
buildConfig,
|
buildConfig,
|
||||||
adminOptions,
|
adminOptions,
|
||||||
serveStatic,
|
serveStatic,
|
||||||
|
beforeBuild,
|
||||||
...serveOptions
|
...serveOptions
|
||||||
}: BunBkndConfig<Env> = {},
|
}: BunBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = Bun.env as Env,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
) {
|
) {
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
...serveOptions,
|
...(serveOptions as any),
|
||||||
port,
|
port,
|
||||||
fetch: createHandler(
|
fetch: createHandler(
|
||||||
{
|
{
|
||||||
|
app,
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
config: _config,
|
||||||
options,
|
options,
|
||||||
onBuilt,
|
onBuilt,
|
||||||
buildConfig,
|
buildConfig,
|
||||||
adminOptions,
|
adminOptions,
|
||||||
distPath,
|
distPath,
|
||||||
serveStatic,
|
serveStatic,
|
||||||
|
beforeBuild,
|
||||||
},
|
},
|
||||||
args,
|
args,
|
||||||
opts,
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
import { bunSqlite } from "./BunSqliteConnection";
|
import { bunSqlite } from "./BunSqliteConnection";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
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 { Database } from "bun:sqlite";
|
||||||
|
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
|
||||||
|
|
||||||
describe("BunSqliteConnection", () => {
|
describe("BunSqliteConnection", () => {
|
||||||
connectionTestSuite(bunTestRunner, {
|
connectionTestSuite(bunTestRunner, {
|
||||||
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
|
|||||||
}),
|
}),
|
||||||
rawDialectDetails: [],
|
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 { 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 BunSqliteConnection = GenericSqliteConnection<Database>;
|
||||||
export type BunSqliteConnectionConfig = {
|
export type BunSqliteConnectionConfig = Omit<
|
||||||
database: Database;
|
GenericSqliteConnectionConfig<Database>,
|
||||||
};
|
"name" | "supports"
|
||||||
|
> &
|
||||||
|
({ database?: Database; url?: never } | { url?: string; database?: never });
|
||||||
|
|
||||||
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
|
export function bunSqlite(config?: BunSqliteConnectionConfig) {
|
||||||
let db: Database;
|
let db: Database | undefined;
|
||||||
if (config) {
|
if (config) {
|
||||||
if ("database" in config) {
|
if ("database" in config && config.database) {
|
||||||
db = config.database;
|
db = config.database;
|
||||||
} else {
|
} else if (config.url) {
|
||||||
db = new Database(config.url);
|
db = new Database(config.url);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if (!db) {
|
||||||
db = new Database(":memory:");
|
db = new Database(":memory:");
|
||||||
}
|
}
|
||||||
|
|
||||||
return genericSqlite("bun-sqlite", db, (utils) => {
|
return genericSqlite(
|
||||||
//const fn = cache ? "query" : "prepare";
|
"bun-sqlite",
|
||||||
const getStmt = (sql: string) => db.prepare(sql);
|
db,
|
||||||
|
(utils) => {
|
||||||
|
const getStmt = (sql: string) => db.prepare(sql);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db,
|
db,
|
||||||
query: utils.buildQueryFn({
|
query: utils.buildQueryFn({
|
||||||
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
|
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
|
||||||
run: (sql, parameters) => {
|
run: (sql, parameters) => {
|
||||||
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
|
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
|
||||||
return {
|
return {
|
||||||
insertId: utils.parseBigInt(lastInsertRowid),
|
insertId: utils.parseBigInt(lastInsertRowid),
|
||||||
numAffectedRows: utils.parseBigInt(changes),
|
numAffectedRows: utils.parseBigInt(changes),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
close: () => db.close(),
|
close: () => db.close(),
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
export * from "./bun.adapter";
|
export * from "./bun.adapter";
|
||||||
export * from "../node/storage";
|
export * from "../node/storage";
|
||||||
export * from "./connection/BunSqliteConnection";
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = {
|
export const bunTestRunner = {
|
||||||
describe,
|
describe,
|
||||||
@@ -8,4 +8,5 @@ export const bunTestRunner = {
|
|||||||
beforeEach,
|
beforeEach,
|
||||||
afterEach,
|
afterEach,
|
||||||
afterAll,
|
afterAll,
|
||||||
|
beforeAll,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,31 +20,31 @@ describe("cf adapter", () => {
|
|||||||
const staticConfig = await makeConfig(
|
const staticConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
initialConfig: { data: { basepath: DB_URL } },
|
config: { data: { basepath: DB_URL } },
|
||||||
},
|
},
|
||||||
$ctx({ 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();
|
expect(staticConfig.connection).toBeDefined();
|
||||||
|
|
||||||
const dynamicConfig = await makeConfig(
|
const dynamicConfig = await makeConfig(
|
||||||
{
|
{
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
initialConfig: { data: { basepath: env.DB_URL } },
|
config: { data: { basepath: env.DB_URL } },
|
||||||
connection: { url: env.DB_URL },
|
connection: { url: env.DB_URL },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
$ctx({ 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();
|
expect(dynamicConfig.connection).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
|
||||||
makeApp: async (c, a, o) => {
|
makeApp: async (c, a) => {
|
||||||
return await createApp(c, { env: a } as any, o);
|
return await createApp(c, { env: a } as any);
|
||||||
},
|
},
|
||||||
makeHandler: (c, a, o) => {
|
makeHandler: (c, a) => {
|
||||||
console.log("args", a);
|
console.log("args", a);
|
||||||
return async (request: any) => {
|
return async (request: any) => {
|
||||||
const app = await createApp(
|
const app = await createApp(
|
||||||
@@ -53,7 +53,6 @@ describe("cf adapter", () => {
|
|||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
a as any,
|
a as any,
|
||||||
o,
|
|
||||||
);
|
);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import type { RuntimeBkndConfig } from "bknd/adapter";
|
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import type { MaybePromise } from "bknd";
|
import type { App, MaybePromise } from "bknd";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -34,33 +34,33 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
ctx: Partial<CloudflareContext<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) => {
|
onBuilt: async (app) => {
|
||||||
if (ctx.ctx) {
|
if (ctx.ctx) {
|
||||||
registerAsyncsExecutionContext(app, 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
|
// compatiblity
|
||||||
export const getFresh = createApp;
|
export const getFresh = createApp;
|
||||||
|
|
||||||
|
let app: App | undefined;
|
||||||
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env> = {},
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
|
serveOptions?: (args: Env) => {
|
||||||
|
warm?: boolean;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
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 { warm } = serveOptions?.(env) ?? {};
|
||||||
const app = await createApp(config, context);
|
if (!app || warm !== true) {
|
||||||
|
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||||
|
app = await createApp(config, context);
|
||||||
|
}
|
||||||
|
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import { describe, test, expect } from "vitest";
|
import { describe, beforeAll, afterAll } from "vitest";
|
||||||
|
|
||||||
import { viTestRunner } from "adapter/node/vitest";
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
import { Miniflare } from "miniflare";
|
import { Miniflare } from "miniflare";
|
||||||
import { doSqlite } from "./DoConnection";
|
import { doSqlite } from "./DoConnection";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
const script = `
|
const script = `
|
||||||
import { DurableObject } from "cloudflare:workers";
|
import { DurableObject } from "cloudflare:workers";
|
||||||
@@ -40,6 +41,9 @@ export default {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(() => enableConsoleLog());
|
||||||
|
|
||||||
describe("doSqlite", async () => {
|
describe("doSqlite", async () => {
|
||||||
connectionTestSuite(viTestRunner, {
|
connectionTestSuite(viTestRunner, {
|
||||||
makeConnection: async () => {
|
makeConnection: async () => {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
|
|||||||
import { viTestRunner } from "adapter/node/vitest";
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
|
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
|
||||||
import { Miniflare } from "miniflare";
|
import { Miniflare } from "miniflare";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(() => enableConsoleLog());
|
||||||
|
|
||||||
describe("cacheWorkersKV", async () => {
|
describe("cacheWorkersKV", async () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
export { constants, type CloudflareContext } from "./config";
|
export { constants, makeConfig, type CloudflareContext } from "./config";
|
||||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||||
export { registries } from "bknd";
|
export { registries } from "bknd";
|
||||||
export { devFsVitePlugin, devFsWrite } from "./vite";
|
export { devFsVitePlugin, devFsWrite } from "./vite";
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import {
|
|||||||
type CloudflareBkndConfig,
|
type CloudflareBkndConfig,
|
||||||
type CloudflareEnv,
|
type CloudflareEnv,
|
||||||
} from "bknd/adapter/cloudflare";
|
} from "bknd/adapter/cloudflare";
|
||||||
import type { PlatformProxy } from "wrangler";
|
import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import { $console } from "bknd/utils";
|
||||||
|
|
||||||
export type WithPlatformProxyOptions = {
|
export type WithPlatformProxyOptions = {
|
||||||
/**
|
/**
|
||||||
@@ -14,22 +15,49 @@ export type WithPlatformProxyOptions = {
|
|||||||
* You can override/force this by setting this option.
|
* You can override/force this by setting this option.
|
||||||
*/
|
*/
|
||||||
useProxy?: boolean;
|
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>(
|
export function withPlatformProxy<Env extends CloudflareEnv>(
|
||||||
config?: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
opts?: WithPlatformProxyOptions,
|
opts?: WithPlatformProxyOptions,
|
||||||
) {
|
) {
|
||||||
const use_proxy =
|
const use_proxy =
|
||||||
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
|
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
|
||||||
let proxy: PlatformProxy | undefined;
|
let proxy: PlatformProxy | undefined;
|
||||||
|
|
||||||
|
$console.log("Using cloudflare platform proxy");
|
||||||
|
|
||||||
async function getEnv(env?: Env): Promise<Env> {
|
async function getEnv(env?: Env): Promise<Env> {
|
||||||
if (use_proxy) {
|
if (use_proxy) {
|
||||||
if (!proxy) {
|
if (!proxy) {
|
||||||
const getPlatformProxy = await import("wrangler").then((mod) => mod.getPlatformProxy);
|
proxy = await getPlatformProxy(opts?.proxyOptions);
|
||||||
proxy = await getPlatformProxy();
|
process.on("exit", () => {
|
||||||
setTimeout(proxy?.dispose, 1000);
|
proxy?.dispose();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return proxy.env as unknown as Env;
|
return proxy.env as unknown as Env;
|
||||||
}
|
}
|
||||||
@@ -37,31 +65,31 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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
|
// @ts-ignore
|
||||||
app: async (_env) => {
|
app: async (_env) => {
|
||||||
const env = await getEnv(_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) {
|
return {
|
||||||
const binding = getBinding(env, "D1Database");
|
...appConfig,
|
||||||
return {
|
beforeBuild: async (app, registries) => {
|
||||||
connection: d1Sqlite({
|
if (!use_proxy) return;
|
||||||
binding: binding.value,
|
const env = await getEnv();
|
||||||
}),
|
registerMedia(env, registries as any);
|
||||||
};
|
await config?.beforeBuild?.(app, registries);
|
||||||
} else if (typeof config?.app === "function") {
|
},
|
||||||
return config?.app(env);
|
bindings: async (env) => {
|
||||||
}
|
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||||
return config?.app || {};
|
},
|
||||||
|
connection,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
} satisfies CloudflareBkndConfig<Env>;
|
} satisfies CloudflareBkndConfig<Env>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export function registerMedia(
|
|||||||
* @todo: add tests (bun tests won't work, need node native tests)
|
* @todo: add tests (bun tests won't work, need node native tests)
|
||||||
*/
|
*/
|
||||||
export class StorageR2Adapter extends StorageAdapter {
|
export class StorageR2Adapter extends StorageAdapter {
|
||||||
|
public keyPrefix: string = "";
|
||||||
|
|
||||||
constructor(private readonly bucket: R2Bucket) {
|
constructor(private readonly bucket: R2Bucket) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -175,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected getKey(key: string) {
|
protected getKey(key: string) {
|
||||||
|
if (this.keyPrefix.length > 0) {
|
||||||
|
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
|
||||||
|
}
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
|
|||||||
import { StorageR2Adapter } from "./StorageR2Adapter";
|
import { StorageR2Adapter } from "./StorageR2Adapter";
|
||||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||||
import path from "node:path";
|
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 { viTestRunner } from "adapter/node/vitest";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog());
|
||||||
|
afterAll(() => enableConsoleLog());
|
||||||
|
|
||||||
let mf: Miniflare | undefined;
|
let mf: Miniflare | undefined;
|
||||||
describe("StorageR2Adapter", async () => {
|
describe("StorageR2Adapter", async () => {
|
||||||
@@ -24,7 +28,8 @@ describe("StorageR2Adapter", async () => {
|
|||||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||||
const file = new File([buffer], "image.png", { type: "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 () => {
|
afterAll(async () => {
|
||||||
|
|||||||
@@ -24,45 +24,157 @@ export function devFsVitePlugin({
|
|||||||
projectRoot = config.root;
|
projectRoot = config.root;
|
||||||
},
|
},
|
||||||
configureServer(server) {
|
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
|
// Intercept stdout to watch for our write requests
|
||||||
const originalStdoutWrite = process.stdout.write;
|
const originalStdoutWrite = process.stdout.write;
|
||||||
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
|
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
|
||||||
const output = chunk.toString();
|
const output = chunk.toString();
|
||||||
|
|
||||||
// Check if this output contains our special write request
|
// Skip our own debug output
|
||||||
if (output.includes("{{DEV_FS_WRITE_REQUEST}}")) {
|
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
||||||
try {
|
// @ts-ignore
|
||||||
// Extract the JSON from the log line
|
// biome-ignore lint/style/noArguments: <explanation>
|
||||||
const match = output.match(/{{DEV_FS_WRITE_REQUEST}} ({.*})/);
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||||
if (match) {
|
}
|
||||||
const writeRequest = JSON.parse(match[1]);
|
|
||||||
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
|
|
||||||
if (verbose) {
|
|
||||||
console.debug("[dev-fs-plugin] Intercepted write request via stdout");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the write request immediately
|
// Track if we process any protocol messages (to suppress output)
|
||||||
(async () => {
|
let processedProtocolMessage = false;
|
||||||
try {
|
|
||||||
const fullPath = resolve(projectRoot, writeRequest.filename);
|
|
||||||
await nodeWriteFile(fullPath, writeRequest.data);
|
|
||||||
if (verbose) {
|
|
||||||
console.debug("[dev-fs-plugin] File written successfully!");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[dev-fs-plugin] Error writing file:", error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Don't output the raw write request to console
|
// Process all start markers in this output
|
||||||
return true;
|
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
|
// @ts-ignore
|
||||||
@@ -78,7 +190,10 @@ export function devFsVitePlugin({
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
transform(code, id, options) {
|
transform(code, id, options) {
|
||||||
// Only transform in SSR mode during development
|
// 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
|
// Check if this is the bknd config file
|
||||||
if (id.includes(configFile)) {
|
if (id.includes(configFile)) {
|
||||||
@@ -92,7 +207,7 @@ export function devFsVitePlugin({
|
|||||||
if (typeof globalThis !== 'undefined') {
|
if (typeof globalThis !== 'undefined') {
|
||||||
globalThis.__devFsPolyfill = {
|
globalThis.__devFsPolyfill = {
|
||||||
writeFile: async (filename, data) => {
|
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
|
// Use console logging as a communication channel
|
||||||
// The main process will watch for this specific log pattern
|
// The main process will watch for this specific log pattern
|
||||||
@@ -103,16 +218,38 @@ if (typeof globalThis !== 'undefined') {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Output as a specially formatted console message
|
// Output as a specially formatted console message with end delimiter
|
||||||
console.log('{{DEV_FS_WRITE_REQUEST}}', JSON.stringify(writeRequest));
|
// Base64 encode the JSON to avoid any control character issues
|
||||||
${verbose ? "console.debug('dev-fs polyfill: Write request sent via console');" : ""}
|
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 Promise.resolve();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}`;
|
||||||
`;
|
|
||||||
return polyfill + code;
|
return polyfill + code;
|
||||||
|
} else {
|
||||||
|
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} satisfies Plugin;
|
} satisfies Plugin;
|
||||||
|
|||||||
+52
-52
@@ -6,28 +6,27 @@ import {
|
|||||||
guessMimeType,
|
guessMimeType,
|
||||||
type MaybePromise,
|
type MaybePromise,
|
||||||
registries as $registries,
|
registries as $registries,
|
||||||
|
type Merge,
|
||||||
} from "bknd";
|
} from "bknd";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import type { Manifest } from "vite";
|
import type { Manifest } from "vite";
|
||||||
|
|
||||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||||
app?: CreateAppConfig | ((args: Args) => MaybePromise<CreateAppConfig>);
|
CreateAppConfig &
|
||||||
onBuilt?: (app: App) => Promise<void>;
|
Omit<Additional, "app"> & {
|
||||||
beforeBuild?: (app: App, registries?: typeof $registries) => Promise<void>;
|
app?:
|
||||||
buildConfig?: Parameters<App["build"]>[0];
|
| 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 FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||||
|
|
||||||
export type CreateAdapterAppOptions = {
|
|
||||||
force?: boolean;
|
|
||||||
id?: string;
|
|
||||||
};
|
|
||||||
export type FrameworkOptions = CreateAdapterAppOptions;
|
|
||||||
export type RuntimeOptions = CreateAdapterAppOptions;
|
|
||||||
|
|
||||||
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||||
distPath?: string;
|
distPath?: string;
|
||||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||||
@@ -41,7 +40,7 @@ export type DefaultArgs = {
|
|||||||
export async function makeConfig<Args = DefaultArgs>(
|
export async function makeConfig<Args = DefaultArgs>(
|
||||||
config: BkndConfig<Args>,
|
config: BkndConfig<Args>,
|
||||||
args?: Args,
|
args?: Args,
|
||||||
): Promise<CreateAppConfig> {
|
): Promise<Omit<BkndConfig<Args>, "app">> {
|
||||||
let additionalConfig: CreateAppConfig = {};
|
let additionalConfig: CreateAppConfig = {};
|
||||||
const { app, ...rest } = config;
|
const { app, ...rest } = config;
|
||||||
if (app) {
|
if (app) {
|
||||||
@@ -58,59 +57,50 @@ export async function makeConfig<Args = DefaultArgs>(
|
|||||||
return { ...rest, ...additionalConfig };
|
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>(
|
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
||||||
config: Config = {} as Config,
|
config: Config = {} as Config,
|
||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: CreateAdapterAppOptions,
|
): Promise<{ app: App; config: BkndConfig<Args> }> {
|
||||||
): Promise<App> {
|
await config.beforeBuild?.(undefined, $registries);
|
||||||
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);
|
|
||||||
}
|
|
||||||
appConfig.connection = connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
app = App.create(appConfig);
|
const appConfig = await makeConfig(config, args);
|
||||||
|
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||||
if (!opts?.force) {
|
let connection: Connection | undefined;
|
||||||
apps.set(id, app);
|
if (Connection.isConnection(config.connection)) {
|
||||||
|
connection = config.connection;
|
||||||
|
} else {
|
||||||
|
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>(
|
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||||
config: FrameworkBkndConfig = {},
|
config: FrameworkBkndConfig = {},
|
||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const app = await createAdapterApp(config, args, opts);
|
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||||
|
|
||||||
if (!app.isBuilt()) {
|
if (!app.isBuilt()) {
|
||||||
if (config.onBuilt) {
|
if (config.onBuilt) {
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
App.Events.AppBuiltEvent,
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
await config.onBuilt?.(app);
|
await appConfig.onBuilt?.(app);
|
||||||
},
|
},
|
||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.beforeBuild?.(app, $registries);
|
await appConfig.beforeBuild?.(app, $registries);
|
||||||
await app.build(config.buildConfig);
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +110,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
|
|||||||
export async function createRuntimeApp<Args = DefaultArgs>(
|
export async function createRuntimeApp<Args = DefaultArgs>(
|
||||||
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
||||||
args?: Args,
|
args?: Args,
|
||||||
opts?: RuntimeOptions,
|
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const app = await createAdapterApp(config, args, opts);
|
const { app, config: appConfig } = await createAdapterApp(config, args);
|
||||||
|
|
||||||
if (!app.isBuilt()) {
|
if (!app.isBuilt()) {
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
@@ -135,7 +124,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
|||||||
app.modules.server.get(path, handler);
|
app.modules.server.get(path, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.onBuilt?.(app);
|
await appConfig.onBuilt?.(app);
|
||||||
if (adminOptions !== false) {
|
if (adminOptions !== false) {
|
||||||
app.registerAdminController(adminOptions);
|
app.registerAdminController(adminOptions);
|
||||||
}
|
}
|
||||||
@@ -143,7 +132,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
|
|||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
|
|
||||||
await config.beforeBuild?.(app, $registries);
|
await appConfig.beforeBuild?.(app, $registries);
|
||||||
await app.build(config.buildConfig);
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,22 +155,33 @@ 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;
|
let files: string[] | undefined;
|
||||||
|
const pkg = opts?.package ?? "bknd";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
return async (c: Context, next: Next) => {
|
return async (c: Context, next: Next) => {
|
||||||
if (!files) {
|
if (!files) {
|
||||||
const manifest =
|
const manifest =
|
||||||
opts?.manifest || ((await import("bknd/dist/manifest.json")).default as Manifest);
|
opts?.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 || [])]);
|
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = c.req.path.substring(1);
|
const path = c.req.path.substring(1);
|
||||||
if (files.includes(path)) {
|
if (files.includes(path)) {
|
||||||
try {
|
try {
|
||||||
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
|
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`;
|
||||||
assert: { type: "text" },
|
const content = await import(/* @vite-ignore */ url, {
|
||||||
|
with: { type: "text" },
|
||||||
}).then((m) => m.default);
|
}).then((m) => m.default);
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
@@ -193,7 +193,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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);
|
return c.text("File not found", 404);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import { isNode } from "bknd/utils";
|
||||||
|
// @ts-expect-error next is not installed
|
||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
type NextjsEnv = NextApiRequest["env"];
|
type NextjsEnv = NextApiRequest["env"];
|
||||||
@@ -9,17 +10,18 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
|||||||
|
|
||||||
export async function getApp<Env = NextjsEnv>(
|
export async function getApp<Env = NextjsEnv>(
|
||||||
config: NextjsBkndConfig<Env>,
|
config: NextjsBkndConfig<Env>,
|
||||||
args: Env = {} as Env,
|
args: Env = process.env as Env,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
||||||
if (!cleanRequest) return req;
|
if (!cleanRequest) return req;
|
||||||
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
|
cleanRequest?.searchParams?.forEach((k) => {
|
||||||
|
url.searchParams.delete(k);
|
||||||
|
});
|
||||||
|
|
||||||
if (isNode()) {
|
if (isNode()) {
|
||||||
return new Request(url.toString(), {
|
return new Request(url.toString(), {
|
||||||
@@ -40,11 +42,10 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
|||||||
|
|
||||||
export function serve<Env = NextjsEnv>(
|
export function serve<Env = NextjsEnv>(
|
||||||
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
||||||
args: Env = {} as Env,
|
args: Env = process.env as Env,
|
||||||
opts?: FrameworkOptions,
|
|
||||||
) {
|
) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
const app = await getApp(config, args, opts);
|
const app = await getApp(config, args);
|
||||||
const request = getCleanRequest(req, cleanRequest);
|
const request = getCleanRequest(req, cleanRequest);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user