Compare commits

...

56 Commits

Author SHA1 Message Date
Cameron Pak 5ea40093a6 fix: include admin_basepath in absolute navigation paths
Fixes navigate(..., { absolute: true }) only prepending basepath but
missing admin_basepath, causing links like /data instead of /admin/data.

Adds admin_basepath to BkndAdminConfig type.

Amp-Thread-ID: https://ampcode.com/threads/T-019cfd9f-89ca-703c-a86c-99eef60e3fe8
Co-authored-by: Amp <amp@ampcode.com>
2026-03-17 16:20:45 -05:00
dswbx feb3911d46 Merge pull request #322 from bknd-io/fix/auth-strategy-respect-register-setting
fix(auth): `allow_register` was not respected in strategy controllers
2026-03-14 13:43:55 +01:00
dswbx ab545d3ca2 Merge pull request #347 from bknd-io/fix/with-builder-respect-alias
fix: `WithBuilder` produced embedding errored when `mappedBy` was used
2026-03-14 13:42:50 +01:00
dswbx 06c3a6f89b Merge remote-tracking branch 'origin/main' into fix/with-builder-respect-alias
# Conflicts:
#	bun.lock
2026-03-14 13:41:01 +01:00
dswbx 898a825f6e fix(WithBuilder test): updated snapshots 2026-03-14 13:40:35 +01:00
dswbx a5ddf6aa93 Merge pull request #363 from bknd-io/release/0.21
Release 0.21
2026-03-14 13:34:23 +01:00
dswbx 5aa286298c Merge remote-tracking branch 'origin/main' into release/0.21
# Conflicts:
#	bun.lock
2026-03-14 13:31:45 +01:00
dswbx 55cb2bacef chore: upgrade bun.lock 2026-03-14 13:29:47 +01:00
dswbx e6ea3f64d7 Merge pull request #351 from shishantbiswas/main
feat: add Tanstack Start adapter
2026-03-14 13:25:35 +01:00
dswbx a5f5ea2084 Merge pull request #339 from cameronapak/allow-seed
Allow CLI `sync` command to seed data properly when schema changes aren't present
2026-03-14 13:18:33 +01:00
dswbx 6fb8e9a0e7 Merge pull request #218 from cameronapak/cp/216-fix-users-link
Fix Sidebar Users Route Links
2026-03-14 13:12:53 +01:00
dswbx abc6de6ad3 Merge pull request #332 from bknd-io/feat/timestamps-index
feat(plugin/timestamps): add indexStrategy option for entity indexing
2026-03-14 13:07:49 +01:00
Shishant Biswas 71b9a0f546 Merge branch 'bknd-io:main' into main 2026-03-13 17:27:35 +05:30
Cameron Pak 038ec0d672 Merge pull request #357 from cameronapak/cp/add-contributing-md
Adding CONTRIBUTING.md doc
2026-03-08 14:16:10 -05:00
Cameron Pak 7832328a50 Merge pull request #359 from jonaspm/patch-1
Update client:only attribute in astro.mdx
2026-03-03 11:37:59 -06:00
Jonás Perusquía Morales e5bfc602e1 Update client:only attribute in astro.mdx
Astro requires client:only to specify the frontend library used
2026-03-03 11:27:59 -06:00
Cameron Pak 0318e595f2 docs: add CONTRIBUTING.md with dev setup, project structure, and contribution guidelines
Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019ca575-cf6e-7388-9bd4-6ddd4c27609c
2026-02-28 12:40:44 -06:00
shishantbiswas 80b0716a4a Remove unrelated Next.js file changes 2026-02-28 23:32:20 +05:30
Cameron Pak 3894271b17 Merge pull request #353 from jonaspm/docs/auth-change-password
docs: Document changePassword method in auth module
2026-02-28 11:38:33 -06:00
shishantbiswas 8bf8b22e57 refactor: remove duplicate tsup build config and clean up 2026-02-28 23:05:38 +05:30
Cameron Pak 367d5878df Fixed test 2026-02-28 11:26:27 -06:00
Cameron Pak fb429e57e2 fix: resolve undefined variable in MediaApi test and remove dead comment
Amp-Thread-ID: https://ampcode.com/threads/T-019ca53d-a225-706c-923e-28123c84d83e
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 11:21:39 -06:00
Cameron Pak 9008f9c6a3 fix: normalize admin basepath to prevent double slashes in URLs
Strip trailing slashes from adminBasepath in AdminController window context
and wouter Router base to prevent paths like /admin//data when users
configure adminBasepath with a trailing slash.

Amp-Thread-ID: https://ampcode.com/threads/T-019ca537-4cc4-7174-bf9a-5325d782f097
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 11:11:44 -06:00
Cameron Pak 6321a9935a Merge branch 'main' into cp/216-fix-users-link 2026-02-28 11:03:54 -06:00
Cameron Pak eb22289460 Update @types/bun to 1.3.9
This commit updates the `@types/bun` package to version 1.3.9.
2026-02-28 11:00:37 -06:00
shishantbiswas c3198394de docs: add tanstack start integration guide 2026-02-26 09:40:42 +05:30
shishantbiswas 51237f5e1a docs(tanstack-start): add JSDoc comments to adapter functions 2026-02-26 09:40:42 +05:30
shishantbiswas 01483b912f add: example for tanstack start 2026-02-26 09:40:42 +05:30
shishantbiswas 6288faef33 Triggering CI 2026-02-26 09:40:42 +05:30
shishantbiswas 384e337bbd add: Tanstack Start adapter 2026-02-26 09:38:28 +05:30
Jonas Perusquia Morales cc1e1976f7 chore: Document error cases for password update method 2026-02-23 13:03:37 -06:00
Jonas Perusquia Morales 8805f017a1 Update auth docs to clarify server module usage 2026-02-18 11:12:14 -06:00
Jonas Perusquia Morales 0ef06375c0 docs: Document changePassword method in auth module 2026-02-18 11:03:58 -06:00
dswbx 1c114d9533 fix: WithBuilder produced embedding errored when mappedBy was used 2026-02-03 18:15:40 +01:00
cameronapak 11e3488917 Allow seeding without forcing sync 2026-01-19 11:46:48 -06:00
dswbx d9bce049ef feat(plugin/timestamps): add indexStrategy option for entity indexing
Introduced a new `indexStrategy` option in the timestamps plugin to configure index generation for `created_at` and `updated_at` fields. Supports "composite" and "individual" strategies for better control over database indexing. Added test coverage for different index strategies.
2026-01-09 16:04:54 +01:00
cameronapak d00e5fe68c Remove redundant basepath option 2025-12-30 07:34:00 -06:00
cameronapak 6faf0d421f Fix test for Blob to File conversion
The Response.blob() method in JavaScript returns a Blob, not a File.
This means that file-specific metadata like `name` and `lastModified` is
lost. The tests were updated to reflect this by manually constructing a
File object from the Blob, mimicking how client code would handle this
conversion. The MIME type is now correctly preserved from the response
headers when creating the File.
2025-12-30 07:33:55 -06:00
cameronapak 19e51fae63 Refactor AppReduced to use basepath config option
The `admin_basepath` configuration option has been renamed to `basepath`
for clarity and consistency. This change affects how the admin base path
is accessed and utilized within the `AppReduced` utility. Tests have
been updated to reflect this renaming.
2025-12-30 07:09:00 -06:00
cameronapak 7e01cc6101 Updated bun lockfile 2025-12-30 06:59:36 -06:00
cameronapak 103cbd11df Merge branch 'main' into cp/216-fix-users-link 2025-12-30 06:55:20 -06:00
dswbx 09b9a5e857 fix(auth): allow_register was not respected in strategy controllers 2025-12-06 20:51:09 +01:00
dswbx ac891a4b61 Merge remote-tracking branch 'origin/fix/release-0.16-examples' into fork/cameronapak/cp/216-fix-users-link 2025-08-03 13:21:05 +02:00
dswbx 412eab6653 examples: fixing imports due to 0.16 2025-08-03 13:15:01 +02:00
dswbx 2fb07698d5 fixing AppReduced.spec.ts 2025-08-03 13:10:49 +02:00
dswbx 64e400c4f1 ui AppReduced: fixing basepath handling in static and embedded modes 2025-08-03 13:08:04 +02:00
dswbx 86d6e891a8 Merge remote-tracking branch 'origin/main' into fork/cameronapak/cp/216-fix-users-link
# Conflicts:
#	bun.lock
2025-08-03 12:55:20 +02:00
cameronapak 7b86611f35 bun run format 2025-07-22 08:58:30 -05:00
cameronapak 9e316a92f6 refactor: use AppTheme type and handle empty admin_basepath
Replace literal theme union with AppTheme type in AdminController options and
make path helpers resilient when admin_basepath is empty.
2025-07-22 08:57:06 -05:00
cameronapak 12ef72d595 test: add comprehensive tests for AppReduced utility and path normalization 2025-07-22 08:52:32 -05:00
cameronapak 75c29ae5e2 Add .env.local to app/.gitignore 2025-07-22 08:37:31 -05:00
cameronapak f0551f50d1 fix: set default admin_basepath and logo_return_path in constructor
Move default option values into the constructor parameter initializer to
prevent them from being overwritten by the spread operator in
`get options()`.
2025-07-22 08:36:38 -05:00
cameronapak 03aac3437b refactor: unify theme type and admin options from AdminController 2025-07-22 08:33:42 -05:00
cameronapak f07f3d1007 refactor: rename basepath to admin_basepath and normalize admin paths 2025-07-22 08:28:23 -05:00
cameronapak c833b2b539 feat: add @clack/prompts because somehow it was missing? 2025-07-22 05:53:23 -05:00
cameronapak 6070394cca Updated bun.lock file 2025-07-22 05:50:44 -05:00
63 changed files with 2425 additions and 439 deletions
+267
View File
@@ -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
+16
View File
@@ -165,3 +165,19 @@ npx bknd run
```bash
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).
+2 -1
View File
@@ -1,4 +1,5 @@
playwright-report
test-results
bknd.config.*
__test__/helper.d.ts
__test__/helper.d.ts
.env.local
+4 -3
View File
@@ -110,10 +110,11 @@ describe("MediaApi", () => {
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const res = await api.getFileStream(name);
expect(isReadableStream(res)).toBe(true);
const res = await api.getFile(name);
const stream = await api.getFileStream(name);
expect(isReadableStream(stream)).toBe(true);
const blob = (await new Response(res).blob()) as File;
const blob = (await res.res.blob()) as File;
expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png");
+5 -13
View File
@@ -88,7 +88,7 @@ describe("[data] WithBuilder", async () => {
const res2 = qb2.compile();
expect(res2.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) 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]);
});
@@ -192,9 +192,7 @@ describe("[data] WithBuilder", async () => {
{ single: {} },
);
const res = qb.compile();
expect(res.sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
);
expect(res.sql).toMatchSnapshot();
expect(res.parameters).toEqual(["categories.single", 1]);
const qb2 = WithBuilder.addClause(
@@ -204,9 +202,7 @@ describe("[data] WithBuilder", async () => {
{ multiple: {} },
);
const res2 = qb2.compile();
expect(res2.sql).toBe(
'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.sql).toMatchSnapshot();
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
});
@@ -271,9 +267,7 @@ describe("[data] WithBuilder", async () => {
);
//prettyPrintQb(qb);
expect(qb.compile().sql).toBe(
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) 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().sql).toMatchSnapshot();
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
});
@@ -313,9 +307,7 @@ describe("[data] WithBuilder", async () => {
},
);
expect(qb.compile().sql).toBe(
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) 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().sql).toMatchSnapshot();
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""`;
@@ -0,0 +1,288 @@
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_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_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_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_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");
});
});
});
+310 -285
View File
@@ -14,15 +14,15 @@ const clean = args.includes("--clean");
// silence tsup
const oldConsole = {
log: console.log,
warn: console.warn,
log: console.log,
warn: console.warn,
};
console.log = () => {};
console.warn = () => {};
const define = {
__isDev: "0",
__version: JSON.stringify(pkg.version),
__isDev: "0",
__version: JSON.stringify(pkg.version),
};
if (clean) {
@@ -43,146 +43,149 @@ if (clean) {
let types_running = false;
function buildTypes() {
if (types_running || !types) return;
types_running = true;
if (types_running || !types) return;
types_running = true;
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false;
},
});
},
});
Bun.spawn(["bun", "build:types"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("built"));
Bun.spawn(["bun", "tsc-alias"], {
stdout: "inherit",
onExit: () => {
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
types_running = false;
},
});
},
});
}
if (types && !watch) {
buildTypes();
buildTypes();
}
let watcher_timeout: any;
function delayTypes() {
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
if (!watch || !types) return;
if (watcher_timeout) {
clearTimeout(watcher_timeout);
}
watcher_timeout = setTimeout(buildTypes, 1000);
}
const dependencies = Object.keys(pkg.dependencies);
// collection of always-external packages
const external = [
...dependencies,
"bun:test",
"node:test",
"node:assert/strict",
"@libsql/client",
"bknd",
/^bknd\/.*/,
"jsonv-ts",
/^jsonv-ts\/.*/,
...dependencies,
"bun:test",
"node:test",
"node:assert/strict",
"@libsql/client",
"bknd",
/^bknd\/.*/,
"jsonv-ts",
/^jsonv-ts\/.*/,
] as const;
/**
* Building backend and general API
*/
async function buildApi() {
await tsup.build({
minify,
sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves
watch: false,
define,
entry: [
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
removeNodeProtocol: false,
format: ["esm"],
splitting: false,
loader: {
".svg": "dataurl",
},
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built"));
},
});
await tsup.build({
minify,
sourcemap,
// don't use tsup's broken watch, we'll handle it ourselves
watch: false,
define,
entry: [
"src/index.ts",
"src/core/utils/index.ts",
"src/plugins/index.ts",
"src/modes/index.ts",
],
outDir: "dist",
external: [...external],
metafile: true,
target: "esnext",
platform: "browser",
removeNodeProtocol: false,
format: ["esm"],
splitting: false,
loader: {
".svg": "dataurl",
},
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[API]"), c.green("built"));
},
});
}
async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text();
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
const bundle = await Bun.file(path).text();
await Bun.write(
path,
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
);
}
/**
* Building UI for direct imports
*/
async function buildUi() {
const base = {
minify,
sourcemap,
watch: false,
define,
external: [
...external,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
const base = {
minify,
sourcemap,
watch: false,
define,
external: [
...external,
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
/codemirror/,
"@xyflow/react",
"@mantine/core",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
});
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), c.green("built"));
},
});
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
});
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
},
});
}
/**
@@ -191,171 +194,185 @@ async function buildUi() {
* - ui/client is external, and after built replaced with "bknd/client"
*/
async function buildUiElements() {
await tsup.build({
minify,
sourcemap,
watch: false,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.alias = {
// not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
};
},
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
},
});
await tsup.build({
minify,
sourcemap,
watch: false,
define,
entry: ["src/ui/elements/index.ts"],
outDir: "dist/ui/elements",
external: [
"ui/client",
"bknd",
/^bknd\/.*/,
"wouter",
"react",
"react-dom",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"use-sync-external-store",
],
metafile: true,
platform: "browser",
format: ["esm"],
splitting: false,
bundle: true,
treeshake: true,
loader: {
".svg": "dataurl",
},
esbuildOptions: (options) => {
options.alias = {
// not important for elements, mock to reduce bundle
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
};
},
onSuccess: async () => {
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
},
});
}
/**
* Building adapters
*/
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
return {
minify,
sourcemap,
watch: false,
entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
removeNodeProtocol: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
},
...overrides,
define: {
...define,
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
function baseConfig(
adapter: string,
overrides: Partial<tsup.Options> = {},
): tsup.Options {
return {
minify,
sourcemap,
watch: false,
entry: [`src/adapter/${adapter}/index.ts`],
format: ["esm"],
platform: "neutral",
outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
removeNodeProtocol: false,
onSuccess: async () => {
delayTypes();
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
},
...overrides,
define: {
...define,
...overrides.define,
},
external: [
/^cloudflare*/,
/^@?hono.*?/,
/^(bknd|react|next|node).*?/,
/.*\.(html)$/,
...external,
...(Array.isArray(overrides.external) ? overrides.external : []),
],
};
}
async function buildAdapters() {
await Promise.all([
// base adapter handles
tsup.build({
...baseConfig(""),
target: "esnext",
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}),
await Promise.all([
// base adapter handles
tsup.build({
...baseConfig(""),
target: "esnext",
platform: "neutral",
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter",
// only way to keep @vite-ignore comments
minify: false,
}),
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("vite"),
platform: "node",
// specific adatpers
tsup.build(baseConfig("react-router")),
tsup.build(
baseConfig("browser", {
external: [/^sqlocal\/?.*?/, "wouter"],
}),
tsup.build({
...baseConfig("nextjs"),
platform: "node",
),
tsup.build(
baseConfig("bun", {
external: [/^bun\:.*/],
}),
tsup.build({
...baseConfig("sveltekit"),
platform: "node",
),
tsup.build(baseConfig("astro")),
tsup.build(baseConfig("aws")),
tsup.build(
baseConfig("cloudflare", {
external: ["wrangler", "node:process"],
}),
tsup.build({
...baseConfig("node"),
platform: "node",
),
tsup.build(
baseConfig("cloudflare/proxy", {
target: "esnext",
entry: ["src/adapter/cloudflare/proxy.ts"],
outDir: "dist/adapter/cloudflare",
metafile: false,
external: [/bknd/, "wrangler", "node:process"],
}),
),
tsup.build({
...baseConfig("sqlite/edge"),
entry: ["src/adapter/sqlite/edge.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
}),
tsup.build({
...baseConfig("vite"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/node"),
entry: ["src/adapter/sqlite/node.ts"],
outDir: "dist/adapter/sqlite",
platform: "node",
metafile: false,
}),
tsup.build({
...baseConfig("nextjs"),
platform: "node",
}),
tsup.build({
...baseConfig("sqlite/bun"),
entry: ["src/adapter/sqlite/bun.ts"],
outDir: "dist/adapter/sqlite",
metafile: false,
external: [/^bun\:.*/],
}),
]);
tsup.build({
...baseConfig("tanstack-start"),
platform: "node",
}),
tsup.build({
...baseConfig("sveltekit"),
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\:.*/],
}),
]);
}
async function buildAll() {
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
await Promise.all([
buildApi(),
buildUi(),
buildUiElements(),
buildAdapters(),
]);
}
// initial build
@@ -363,39 +380,47 @@ 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/...");
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let isBuilding = false;
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 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);
};
const debouncedRebuild = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(rebuild, 100);
};
// watch src directory recursively
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
// 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;
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(() => {});
// keep process alive
await new Promise(() => {});
}
+36 -33
View File
@@ -1,44 +1,47 @@
const adapter = process.env.TEST_ADAPTER;
const default_config = {
media_adapter: "local",
base_path: "",
media_adapter: "local",
base_path: "",
} as const;
const configs = {
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
cloudflare: {
media_adapter: "r2",
},
"react-router": {
base_path: "/admin",
},
nextjs: {
base_path: "/admin",
},
astro: {
base_path: "/admin",
},
node: {
base_path: "",
},
bun: {
base_path: "",
},
"tanstack-start": {
base_path: "/admin",
},
};
export function getAdapterConfig(): typeof default_config {
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
if (adapter) {
if (!configs[adapter]) {
console.warn(
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
);
} else {
return {
...default_config,
...configs[adapter],
};
}
}
return default_config;
return default_config;
}
+6
View File
@@ -268,6 +268,11 @@
"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/styles.css": "./dist/ui/styles.css",
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
@@ -286,6 +291,7 @@
"adapter/bun": ["./dist/types/adapter/bun/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"]
}
},
+1
View File
@@ -0,0 +1 @@
export * from "./tanstack-start.adapter";
@@ -0,0 +1,16 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as tanstackStart from "./tanstack-start.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { TanstackStartConfig } from "./tanstack-start.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("tanstack start adapter", () => {
adapterTestSuite<TanstackStartConfig>(bunTestRunner, {
makeApp: tanstackStart.getApp,
makeHandler: tanstackStart.serve,
});
});
@@ -0,0 +1,33 @@
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
export type TanstackStartEnv = NodeJS.ProcessEnv;
export type TanstackStartConfig<Env = TanstackStartEnv> =
FrameworkBkndConfig<Env>;
/**
* Get bknd app instance
* @param config - bknd configuration
* @param args - environment variables
*/
export async function getApp<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args);
}
/**
* Create request handler for src/routes/api.$.ts
* @param config - bknd configuration
* @param args - environment variables
*/
export function serve<Env = TanstackStartEnv>(
config: TanstackStartConfig<Env> = {},
args: Env = process.env as Env,
) {
return async (request: Request) => {
const app = await getApp(config, args);
return app.fetch(request);
};
}
+9 -2
View File
@@ -199,7 +199,12 @@ export class AuthController extends Controller {
for (const [name, strategy] of Object.entries(strategies)) {
if (!this.auth.isStrategyEnabled(strategy)) continue;
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
hono.route(
`/${name}`,
strategy.getController(this.auth.authenticator, {
allow_register: this.auth.config.allow_register,
}),
);
this.registerStrategyActions(strategy, hono);
}
@@ -305,7 +310,9 @@ export class AuthController extends Controller {
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator);
const controller = pw.getController(this.auth.authenticator, {
allow_register: this.auth.config.allow_register,
});
const res = await controller.request(
new Request("https://localhost/login", {
@@ -87,7 +87,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
};
}
getController(authenticator: Authenticator): Hono<any> {
getController(authenticator: Authenticator, opts: { allow_register?: boolean }): Hono<any> {
const hono = new Hono();
const redirectQuerySchema = s.object({
redirect: s.string().optional(),
@@ -120,41 +120,43 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
},
);
hono.post(
"/register",
describeRoute({
summary: "Register a new user with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
payloadSchema,
await authenticator.getBody(c),
{
onError: (errors) => {
$console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
if (opts.allow_register) {
hono.post(
"/register",
describeRoute({
summary: "Register a new user with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
payloadSchema,
await authenticator.getBody(c),
{
onError: (errors) => {
$console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
},
},
},
);
);
const profile = {
...body,
email,
strategy_value: await this.hash(password),
};
const profile = {
...body,
email,
strategy_value: await this.hash(password),
};
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
},
);
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
},
);
}
return hono;
}
@@ -36,7 +36,7 @@ export abstract class AuthStrategy<Schema extends s.Schema = s.Schema> {
protected abstract getSchema(): Schema;
abstract getController(auth: Authenticator): Hono;
abstract getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono;
getType(): string {
return this.type;
@@ -284,7 +284,7 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
}
}
getController(auth: Authenticator): Hono<any> {
getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono<any> {
const hono = new Hono();
const secret = "secret";
const cookie_name = "_challenge";
@@ -379,6 +379,10 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
return c.notFound();
}
if (action === "register" && !opts.allow_register) {
return c.notFound();
}
const url = new URL(c.req.url);
const path = url.pathname.replace(`/${action}`, "");
const redirect_uri = url.origin + path + "/callback";
+33 -32
View File
@@ -21,46 +21,47 @@ export const sync: CliCommand = (program) => {
console.info("");
if (stmts.length === 0) {
console.info(c.yellow("No changes to sync"));
process.exit(0);
}
// @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
} else {
// @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.force) {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop });
if (options.force) {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop });
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
if (options.seed) {
console.info(c.dim("\nExecuting seed..."));
const seed = app.options?.seed;
if (seed) {
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
} else {
if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} else {
console.info(c.yellow("No seed function provided"));
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
}
}
} else {
if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} else {
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
}
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
if (options.seed) {
console.info(c.dim("\nExecuting seed..."));
const seed = app.options?.seed;
if (seed) {
await seed({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
}
}
process.exit(0);
});
};
@@ -39,6 +39,7 @@ export class WithBuilder {
if (query) {
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
alias: other.reference,
});
}
+5 -4
View File
@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
import { Controller } from "modules/Controller";
import * as SystemPermissions from "modules/permissions";
import type { TApiUser } from "Api";
import type { AppTheme } from "ui/client/use-theme";
import type { Manifest } from "vite";
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
@@ -20,7 +21,7 @@ export type AdminBkndWindowContext = {
logout_route: string;
admin_basepath: string;
logo_return_path?: string;
theme?: "dark" | "light" | "system";
theme?: AppTheme;
};
// @todo: add migration to remove admin path from config
@@ -31,7 +32,7 @@ export type AdminControllerOptions = {
html?: string;
forceDev?: boolean | { mainPath: string };
debugRerenders?: boolean;
theme?: "dark" | "light" | "system";
theme?: AppTheme;
logoReturnPath?: string;
manifest?: Manifest;
};
@@ -122,7 +123,7 @@ export class AdminController extends Controller {
const obj: AdminBkndWindowContext = {
user: c.get("auth")?.user,
logout_route: authRoutes.logout,
admin_basepath: this.options.adminBasepath,
admin_basepath: this.options.adminBasepath.replace(/\/+$/, ""),
theme: this.options.theme,
logo_return_path: this.options.logoReturnPath,
};
@@ -304,7 +305,7 @@ const wrapperStyle = css`
-moz-osx-font-smoothing: grayscale;
color: rgb(9,9,11);
background-color: rgb(250,250,250);
@media (prefers-color-scheme: dark) {
color: rgb(250,250,250);
background-color: rgb(30,31,34);
@@ -71,4 +71,54 @@ describe("timestamps plugin", () => {
expect(updatedData.updated_at).toBeDefined();
expect(updatedData.updated_at).toBeInstanceOf(Date);
});
test("index strategy", async () => {
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"] })],
},
});
await app.build();
expect(app.em.indices.length).toBe(0);
{
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"], indexStrategy: "composite" })],
},
});
await app.build();
expect(app.em.indices.length).toBe(1);
}
{
const app = createApp({
config: {
data: em({
posts: entity("posts", {
title: text(),
}),
}).toJSON(),
},
options: {
plugins: [timestamps({ entities: ["posts"], indexStrategy: "individual" })],
},
});
await app.build();
expect(app.em.indices.length).toBe(2);
}
});
});
+27 -9
View File
@@ -4,6 +4,7 @@ import { $console } from "bknd/utils";
export type TimestampsPluginOptions = {
entities: string[];
setUpdatedOnCreate?: boolean;
indexStrategy?: "composite" | "individual";
};
/**
@@ -19,6 +20,7 @@ export type TimestampsPluginOptions = {
export function timestamps({
entities = [],
setUpdatedOnCreate = true,
indexStrategy,
}: TimestampsPluginOptions): AppPlugin {
return (app: App) => ({
name: "timestamps",
@@ -29,19 +31,35 @@ export function timestamps({
}
const appEntities = app.em.entities.map((e) => e.name);
const actualEntities = entities.filter((e) => appEntities.includes(e));
return em(
Object.fromEntries(
entities
.filter((e) => appEntities.includes(e))
.map((e) => [
e,
entity(e, {
created_at: datetime(),
updated_at: datetime(),
}),
]),
actualEntities.map((e) => [
e,
entity(e, {
created_at: datetime(),
updated_at: datetime(),
}),
]),
),
(fns, schema) => {
if (indexStrategy) {
for (const entity of actualEntities) {
if (entity in schema) {
switch (indexStrategy) {
case "composite":
fns.index(schema[entity]!).on(["created_at", "updated_at"]);
break;
case "individual":
fns.index(schema[entity]!).on(["created_at"]);
fns.index(schema[entity]!).on(["updated_at"]);
break;
}
}
}
}
},
);
},
onBuilt: async () => {
+5
View File
@@ -16,6 +16,11 @@ export type BkndAdminConfig = {
* @default `/`
*/
basepath?: string;
/**
* Sub-path for the Admin UI within the base path
* @default ``
*/
admin_basepath?: string;
/**
* Path to return to when clicking the logo
* @default `/`
+12 -6
View File
@@ -22,7 +22,6 @@ export class AppReduced {
protected appJson: AppType,
protected _options: BkndAdminProps["config"] = {},
) {
//console.log("received appjson", appJson);
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
return constructEntity(name, entity);
@@ -70,20 +69,27 @@ export class AppReduced {
get options() {
return {
basepath: "",
basepath: "/",
logo_return_path: "/",
...this._options,
};
}
withBasePath(path: string | string[], absolute = false): string {
const paths = Array.isArray(path) ? path : [path];
return [absolute ? "~" : null, this.options.basepath, this.options.admin_basepath, ...paths]
.filter(Boolean)
.join("/")
.replace(/\/+/g, "/")
.replace(/\/$/, "");
}
getSettingsPath(path: string[] = []): string {
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
return [base, ...path].join("/");
return this.withBasePath(["settings", ...path], true);
}
getAbsolutePath(path?: string): string {
const { basepath } = this.options;
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
return this.withBasePath(path ?? [], true);
}
getAuthConfig() {
+1 -1
View File
@@ -101,7 +101,7 @@ export function useNavigate() {
}
}
const _url = options?.absolute ? `~/${basepath}${url}`.replace(/\/+/g, "/") : url;
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url;
const state = {
...options?.state,
referrer: location,
+1 -1
View File
@@ -33,7 +33,7 @@ export function Routes({
}) {
const { theme } = useTheme();
const ctx = useBkndWindowContext();
const actualBasePath = basePath || ctx.admin_basepath;
const actualBasePath = (basePath || ctx.admin_basepath).replace(/\/+$/, "");
return (
<div id="bknd-admin" className={theme + " antialiased"}>
+9 -9
View File
@@ -1,11 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "./src",
"baseUrl": ".",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./node_modules", "./__test__", "./e2e"]
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/types",
"rootDir": "./src",
"baseUrl": ".",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./node_modules", "./__test__", "./e2e"]
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"configVersion": 1,
"workspaces": {
"": {
"name": "bknd",
@@ -3846,7 +3846,7 @@
"@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
"@bknd/plasmic/@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@bknd/plasmic/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
@@ -4750,7 +4750,7 @@
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="],
"@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
@@ -170,7 +170,7 @@ export const prerender = false;
theme: "dark",
logo_return_path: "/../"
}}
client:only
client:only="react"
/>
</body>
</html>
@@ -1,3 +1,10 @@
{
"pages": ["nextjs", "react-router", "astro", "sveltekit", "vite"]
"pages": [
"nextjs",
"react-router",
"astro",
"sveltekit",
"tanstack-start",
"vite"
]
}
@@ -0,0 +1,291 @@
---
title: "Tanstack Start"
description: "Run bknd inside Tanstack Start"
tags: ["documentation"]
---
## Installation
To get started with Tanstack Start and bknd, create a new Tanstack Start project by following the [official guide](https://tanstack.com/start/latest/docs/framework/react/getting-started#start-a-new-project-from-scratch), and then install bknd as a dependency:
<Tabs groupId='package-manager' persist items={[ 'npm', 'pnpm', 'yarn', 'bun']}>
```bash tab="npm"
npm install bknd
```
```bash tab="pnpm"
pnpm install bknd
```
```bash tab="yarn"
yarn add bknd
```
```bash tab="bun"
bun add bknd
```
</Tabs>
## Configuration
<Callout type="warning">
When run with Node.js, a version of 22 (LTS) or higher is required. Please
verify your version by running `node -v`, and
[upgrade](https://nodejs.org/en/download/) if necessary.
</Callout>
Now create a `bknd.config.ts` file in the root of your project:
```typescript title="bknd.config.ts"
import { type TanstackStartConfig } from "bknd/adapter/tanstack-start";
import { em, entity, text, boolean } from "bknd";
const schema = em({
todos: entity("todos", {
title: text(),
done: boolean(),
}),
});
export default {
connection: {
url: "file:data.db",
},
config: {
data: schema.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "random_gibberish_please_change_this",
// use something like `openssl rand -hex 32` for production
},
},
},
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
// create some entries
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false },
]);
// and create a user
await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});
},
},
} satisfies TanstackStartConfig;
```
For more information about the connection object, refer to the [Database](/usage/database) guide.
See [bknd.config.ts](/extending/config) for more information on how to configure bknd. The `TanstackStartConfig` type extends the base config type with the following properties:
```typescript
export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
```
## Serve the API
The Tanstack Start adapter uses Tanstack Start's hooks mechanism to handle API requests. Create a `/src/routes/api.$.ts` file:
```typescript title="/src/routes/api.$.ts"
import { createFileRoute } from "@tanstack/react-router";
import config from "../../bknd.config";
import { serve } from "bknd/adapter/tanstack-start";
const handler = serve(config);
export const Route = createFileRoute("/api/$")({
server: {
handlers: {
ANY: async ({ request }) => await handler(request),
},
},
});
```
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
```ts title="src/bknd.ts"
import config from "../bknd.config";
import { getApp } from "bknd/adapter/tanstack-start";
export async function getApi({
headers,
verify,
}: {
verify?: boolean;
headers?: Headers;
}) {
const app = await getApp(config, process.env);
if (verify) {
const api = app.getApi({ headers });
await api.verifyAuth();
return api;
}
return app.getApi();
};
```
<Callout type="info">
The adapter uses `process.env` to access environment variables, this works because Tanstack Start uses Nitro underneath and it will use polyfills for `process.env` making it platform/runtime agnostic.
</Callout>
## Enabling the Admin UI
Create a page at /src/routes/admin.$.tsx:
```typescript title="/src/routes/admin.$.tsx"
import { createFileRoute } from "@tanstack/react-router";
import { useAuth } from "bknd/client";
import "bknd/dist/styles.css";
import { Admin } from "bknd/ui";
export const Route = createFileRoute("/admin/$")({
ssr: false, // [!code highlight] "data-only" works too
component: RouteComponent,
});
function RouteComponent() {
const { user } = useAuth();
return (
<Admin
withProvider={{ user: user }}
config={{
basepath: "/admin",
logo_return_path: "/../",
theme: "system",
}}
baseUrl={import.meta.env.APP_URL}
/>
);
};
```
<Callout type="info">
Admin routes are expected to run on the client not using `ssr: false` will cause errors like `✘ [ERROR] No matching export in "node_modules/json-schema-library/dist/index.mjs" for import "Draft2019"` and production build might fail because of this
</Callout>
## Example usage of the API
You can use the `getApp` function to access the bknd API in your app:
These are a few examples how you can validate user and handle server-side requests using `createServerFn`.
```typescript title="src/routes/index.tsx"
import { createFileRoute } from "@tanstack/react-router";
import { getApi } from "@/bknd";
import { createServerFn } from "@tanstack/react-start";
export const getTodo = createServerFn()
.handler(async () => {
const api = await getApi({});
const limit = 5;
const todos = await api.data.readMany("todos", { limit, sort: "-id" });
return { todos };
});
export const Route = createFileRoute("/")({
ssr: false,
component: App,
loader: async () => {
return await getTodo();
},
});
function App() {
const { todos } = Route.useLoaderData();
return (
<div>
<h1>Todos</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
</div>
);
}
```
### Using authentication
To use authentication in your app, pass the request headers to the API:
```typescript title="src/routes/user.tsx"
import { getApi } from "@/bknd";
import { createServerFn } from "@tanstack/react-start";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { getRequest } from "@tanstack/react-start/server";
export const getUser = createServerFn()
.handler(async () => {
const request = getRequest();
const api = await getApi({ verify: true, headers: request.headers });
const user = api.getUser();
return { user };
});
export const Route = createFileRoute("/user")({
component: RouteComponent,
loader: async () => {
return { user: await getUser() };
},
});
function RouteComponent() {
const { user } = Route.useLoaderData();
return (
<div>
{user ? (
<>
Logged in as {user.email}.{" "}
<Link
className="font-medium underline"
to={"/api/auth/logout" as string}
>
Logout
</Link>
</>
) : (
<div className="flex flex-col gap-1">
<p>
Not logged in.
<Link
className="font-medium underline"
to={"/admin/auth/login" as string}
>
Login
</Link>
</p>
<p className="text-xs opacity-50">
Sign in with:
<b>
<code>test@bknd.io</code>
</b>
/
<b>
<code>12345678</code>
</b>
</p>
</div>
)}
</div>
)
}
```
Check the [Tanstack Start repository example](https://github.com/bknd-io/bknd/tree/main/examples/tanstack-start) for more implementation details.
@@ -33,6 +33,12 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
href="/integration/sveltekit"
/>
<Card
icon={<Icon icon="simple-icons:tanstack" className="text-fd-primary !size-6" />}
title="Tanstack Start"
href="/integration/tanstack-start"
/>
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
Create a new issue to request a guide for your framework.
</Card>
@@ -30,6 +30,25 @@ With a focus on flexibility and ease of integration, bknd's authentication syste
---
## Server Module: `module.auth`
### `auth.changePassword([userId], [password])`
To change a user's password, use the `changePassword` method:
```ts
await app.module.auth.changePassword(user.id, password);
```
This method updates the password for the specified user.
- `userId`: The ID of the user whose password should be changed.
- `password`: The new password value.
This method throws an error if:
- The user with the given `userId` is not found.
- The user's authentication strategy is not `"password"` (e.g. the user registered via an OAuth provider).
## Securing Your Admin Portal
<Callout type="warn" title="Do not enable the Guard without an admin user">
@@ -150,6 +150,12 @@ Pick your framework or runtime to get started.
href="/integration/sveltekit"
/>
<Card
icon={<Icon icon="simple-icons:tanstack" className="text-fd-primary !size-6" />}
title="Tanstack Start"
href="/integration/tanstack-start"
/>
<Card
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
title="AWS Lambda"
+16
View File
@@ -0,0 +1,16 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
todos.json
public/uploads
data.db
src/routeTree.gen.ts
+11
View File
@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}
+28
View File
@@ -0,0 +1,28 @@
# bknd + Tanstack Start Example
This is a minimal example to shows how to integrate bknd with Tanstack Start.
## Setup
```bash
bun install
bun run dev
```
## How it works
1. **`bknd.config.ts`** - bknd configuration with database connection, schema, and seed data
2. **`src/routes/api.$.ts`** - Handles `/api/*` requests for bknd
3. **`src/routes/index.tsx`** - Using `getApp()` to fetch data in loader
3. **`src/routes/ssr.tsx`** - Server Side example with `getApp()` to fetch data on server
## API Endpoints
- `GET /admin` - for Admin Dashboard
- `GET /api/data/entity/todos` - List todos (requires auth)
- `POST /api/auth/password/login` - Login
## Test Credentials
- Email: `test@bknd.io`
- Password: `12345678`
+55
View File
@@ -0,0 +1,55 @@
import { em, entity, text, boolean } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { TanstackStartConfig } from "bknd/adapter/tanstack-start";
const local = registerLocalMediaAdapter();
const schema = em({
todos: entity("todos", {
title: text(),
done: boolean(),
}),
});
// register your schema to get automatic type completion
type Database = (typeof schema)["DB"];
declare module "bknd" {
interface DB extends Database {}
}
export default {
connection: {
url: "file:data.db",
},
options: {
// the seed option is only executed if the database was empty
seed: async (ctx) => {
// create some entries
await ctx.em.mutator("todos").insertMany([
{ title: "Learn bknd", done: true },
{ title: "Build something cool", done: false },
]);
// and create a user
await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});
},
},
config: {
data: schema.toJSON(),
auth: {
enabled: true,
jwt: {
secret: "random_gibberish_please_change_this",
},
},
media: {
enabled: true,
adapter: local({
path: "./public/uploads",
}),
},
},
} satisfies TanstackStartConfig;
+42
View File
@@ -0,0 +1,42 @@
{
"name": "my-bknd-app",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"start": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"bknd": "file:../../app",
"lucide-react": "^0.561.0",
"nitro": "^3.0.1-alpha.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.0.2"
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.3.11",
"babel-plugin-react-compiler": "^1.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg
width="578"
height="188"
viewBox="0 0 578 188"
fill="black"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M41.5 34C37.0817 34 33.5 37.5817 33.5 42V146C33.5 150.418 37.0817 154 41.5 154H158.5C162.918 154 166.5 150.418 166.5 146V42C166.5 37.5817 162.918 34 158.5 34H41.5ZM123.434 113.942C124.126 111.752 124.5 109.42 124.5 107C124.5 94.2975 114.203 84 101.5 84C99.1907 84 96.9608 84.3403 94.8579 84.9736L87.2208 65.1172C90.9181 63.4922 93.5 59.7976 93.5 55.5C93.5 49.701 88.799 45 83 45C77.201 45 72.5 49.701 72.5 55.5C72.5 61.299 77.201 66 83 66C83.4453 66 83.8841 65.9723 84.3148 65.9185L92.0483 86.0256C87.1368 88.2423 83.1434 92.1335 80.7957 96.9714L65.4253 91.1648C65.4746 90.7835 65.5 90.3947 65.5 90C65.5 85.0294 61.4706 81 56.5 81C51.5294 81 47.5 85.0294 47.5 90C47.5 94.9706 51.5294 99 56.5 99C60.0181 99 63.0648 96.9814 64.5449 94.0392L79.6655 99.7514C78.9094 102.03 78.5 104.467 78.5 107C78.5 110.387 79.2321 113.603 80.5466 116.498L69.0273 123.731C67.1012 121.449 64.2199 120 61 120C55.201 120 50.5 124.701 50.5 130.5C50.5 136.299 55.201 141 61 141C66.799 141 71.5 136.299 71.5 130.5C71.5 128.997 71.1844 127.569 70.6158 126.276L81.9667 119.149C86.0275 125.664 93.2574 130 101.5 130C110.722 130 118.677 124.572 122.343 116.737L132.747 120.899C132.585 121.573 132.5 122.276 132.5 123C132.5 127.971 136.529 132 141.5 132C146.471 132 150.5 127.971 150.5 123C150.5 118.029 146.471 114 141.5 114C138.32 114 135.525 115.649 133.925 118.139L123.434 113.942Z"
/>
<path d="M243.9 151.5C240.4 151.5 237 151 233.7 150C230.4 149 227.4 147.65 224.7 145.95C222 144.15 219.75 142.15 217.95 139.95C216.15 137.65 215 135.3 214.5 132.9L219.3 131.1L218.25 149.7H198.15V39H219.45V89.25L215.4 87.6C216 85.2 217.15 82.9 218.85 80.7C220.55 78.4 222.7 76.4 225.3 74.7C227.9 72.9 230.75 71.5 233.85 70.5C236.95 69.5 240.15 69 243.45 69C250.35 69 256.5 70.8 261.9 74.4C267.3 77.9 271.55 82.75 274.65 88.95C277.85 95.15 279.45 102.25 279.45 110.25C279.45 118.25 277.9 125.35 274.8 131.55C271.7 137.75 267.45 142.65 262.05 146.25C256.75 149.75 250.7 151.5 243.9 151.5ZM238.8 133.35C242.8 133.35 246.25 132.4 249.15 130.5C252.15 128.5 254.5 125.8 256.2 122.4C257.9 118.9 258.75 114.85 258.75 110.25C258.75 105.75 257.9 101.75 256.2 98.25C254.6 94.75 252.3 92.05 249.3 90.15C246.3 88.25 242.8 87.3 238.8 87.3C234.8 87.3 231.3 88.25 228.3 90.15C225.3 92.05 222.95 94.75 221.25 98.25C219.55 101.75 218.7 105.75 218.7 110.25C218.7 114.85 219.55 118.9 221.25 122.4C222.95 125.8 225.3 128.5 228.3 130.5C231.3 132.4 234.8 133.35 238.8 133.35ZM308.312 126.15L302.012 108.6L339.512 70.65H367.562L308.312 126.15ZM288.062 150V39H309.362V150H288.062ZM341.762 150L313.262 114.15L328.262 102.15L367.412 150H341.762ZM371.675 150V70.65H392.075L392.675 86.85L388.475 88.65C389.575 85.05 391.525 81.8 394.325 78.9C397.225 75.9 400.675 73.5 404.675 71.7C408.675 69.9 412.875 69 417.275 69C423.275 69 428.275 70.2 432.275 72.6C436.375 75 439.425 78.65 441.425 83.55C443.525 88.35 444.575 94.3 444.575 101.4V150H423.275V103.05C423.275 99.45 422.775 96.45 421.775 94.05C420.775 91.65 419.225 89.9 417.125 88.8C415.125 87.6 412.625 87.1 409.625 87.3C407.225 87.3 404.975 87.7 402.875 88.5C400.875 89.2 399.125 90.25 397.625 91.65C396.225 93.05 395.075 94.65 394.175 96.45C393.375 98.25 392.975 100.2 392.975 102.3V150H382.475C380.175 150 378.125 150 376.325 150C374.525 150 372.975 150 371.675 150ZM488.536 151.5C481.636 151.5 475.436 149.75 469.936 146.25C464.436 142.65 460.086 137.8 456.886 131.7C453.786 125.5 452.236 118.35 452.236 110.25C452.236 102.35 453.786 95.3 456.886 89.1C460.086 82.9 464.386 78 469.786 74.4C475.286 70.8 481.536 69 488.536 69C492.236 69 495.786 69.6 499.186 70.8C502.686 71.9 505.786 73.45 508.486 75.45C511.286 77.45 513.536 79.7 515.236 82.2C516.936 84.6 517.886 87.15 518.086 89.85L512.686 90.75V39H533.986V150H513.886L512.986 131.7L517.186 132.15C516.986 134.65 516.086 137.05 514.486 139.35C512.886 141.65 510.736 143.75 508.036 145.65C505.436 147.45 502.436 148.9 499.036 150C495.736 151 492.236 151.5 488.536 151.5ZM493.336 133.8C497.336 133.8 500.836 132.8 503.836 130.8C506.836 128.8 509.186 126.05 510.886 122.55C512.586 119.05 513.436 114.95 513.436 110.25C513.436 105.65 512.586 101.6 510.886 98.1C509.186 94.5 506.836 91.75 503.836 89.85C500.836 87.85 497.336 86.85 493.336 86.85C489.336 86.85 485.836 87.85 482.836 89.85C479.936 91.75 477.636 94.5 475.936 98.1C474.336 101.6 473.536 105.65 473.536 110.25C473.536 114.95 474.336 119.05 475.936 122.55C477.636 126.05 479.936 128.8 482.836 130.8C485.836 132.8 489.336 133.8 493.336 133.8Z" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+20
View File
@@ -0,0 +1,20 @@
import config from "../bknd.config";
import { getApp } from "bknd/adapter/tanstack-start";
export async function getApi({
headers,
verify,
}: {
verify?: boolean;
headers?: Headers;
}) {
const app = await getApp(config, process.env);
if (verify) {
const api = app.getApi({ headers });
await api.verifyAuth();
return api;
}
return app.getApi();
}
@@ -0,0 +1,52 @@
import { useRouterState, Link } from "@tanstack/react-router";
export function Footer() {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
return (
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<Link
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
to={pathname === "/" ? "/ssr" : ("/" as string)}
>
<img
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
{pathname === "/" ? "SSR" : "Home"}
</Link>
<Link
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
to={"/admin" as string}
>
<img
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Admin
</Link>
<Link
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
to={"https://bknd.io" as string}
target="_blank"
rel="noopener noreferrer"
>
<img
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to bknd.io
</Link>
</footer>
);
}
@@ -0,0 +1,9 @@
export const List = ({ items = [] }: { items: React.ReactNode[] }) => (
<ol className="list-inside list-decimal text-sm text-center sm:text-left">
{items.map((item, i) => (
<li key={i} className={i < items.length - 1 ? "mb-2" : ""}>
{item}
</li>
))}
</ol>
);
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1,122 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SsrRouteImport } from './routes/ssr'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiSplatRouteImport } from './routes/api.$'
import { Route as AdminSplatRouteImport } from './routes/admin.$'
const SsrRoute = SsrRouteImport.update({
id: '/ssr',
path: '/ssr',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSplatRoute = ApiSplatRouteImport.update({
id: '/api/$',
path: '/api/$',
getParentRoute: () => rootRouteImport,
} as any)
const AdminSplatRoute = AdminSplatRouteImport.update({
id: '/admin/$',
path: '/admin/$',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/ssr': typeof SsrRoute
'/admin/$': typeof AdminSplatRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/ssr': typeof SsrRoute
'/admin/$': typeof AdminSplatRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/ssr': typeof SsrRoute
'/admin/$': typeof AdminSplatRoute
'/api/$': typeof ApiSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/ssr' | '/admin/$' | '/api/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/ssr' | '/admin/$' | '/api/$'
id: '__root__' | '/' | '/ssr' | '/admin/$' | '/api/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SsrRoute: typeof SsrRoute
AdminSplatRoute: typeof AdminSplatRoute
ApiSplatRoute: typeof ApiSplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/ssr': {
id: '/ssr'
path: '/ssr'
fullPath: '/ssr'
preLoaderRoute: typeof SsrRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/$': {
id: '/api/$'
path: '/api/$'
fullPath: '/api/$'
preLoaderRoute: typeof ApiSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/$': {
id: '/admin/$'
path: '/admin/$'
fullPath: '/admin/$'
preLoaderRoute: typeof AdminSplatRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SsrRoute: SsrRoute,
AdminSplatRoute: AdminSplatRoute,
ApiSplatRoute: ApiSplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
+17
View File
@@ -0,0 +1,17 @@
import { createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
// Create a new router instance
export const getRouter = () => {
const router = createRouter({
routeTree,
context: {},
scrollRestoration: true,
defaultPreloadStaleTime: 0,
})
return router
}
@@ -0,0 +1,58 @@
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { ClientProvider } from "bknd/client";
import appCss from "../styles.css?url";
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: "utf-8",
},
{
name: "viewport",
content: "width=device-width, initial-scale=1",
},
{
title: "TanStack 🤝 Bknd.io",
},
],
links: [
{
rel: "stylesheet",
href: appCss,
},
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<ClientProvider verbose baseUrl={import.meta.env.APP_URL}>
{children}
</ClientProvider>
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
);
}
@@ -0,0 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { useAuth } from "bknd/client";
import "bknd/dist/styles.css";
import { Admin } from "bknd/ui";
export const Route = createFileRoute("/admin/$")({
ssr: false, // "data-only" works too
component: RouteComponent,
});
function RouteComponent() {
const { user } = useAuth();
return (
<Admin
withProvider={{ user: user }}
config={{
basepath: "/admin",
logo_return_path: "/../",
}}
baseUrl={import.meta.env.APP_URL}
/>
);
}
@@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import config from "../../bknd.config";
import { serve } from "bknd/adapter/tanstack-start";
const handler = serve(config);
export const Route = createFileRoute("/api/$")({
server: {
handlers: {
ANY: async ({ request }) => await handler(request),
},
},
});
@@ -0,0 +1,171 @@
import {
createFileRoute,
useRouter,
} from "@tanstack/react-router";
import { getApi } from "@/bknd";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { Footer } from "@/components/Footer";
import { List } from "@/components/List";
export const completeTodo = createServerFn({ method: "POST" })
.inputValidator(
(data) => data as { done: boolean; id: number; title: string },
)
.handler(async ({ data: todo }) => {
try {
const api = await getApi({});
await api.data.updateOne("todos", todo.id, {
done: !todo.done,
});
console.log("state updated in db");
} catch (error) {
console.log(error);
}
});
export const deleteTodo = createServerFn({ method: "POST" })
.inputValidator((data) => data as { id: number })
.handler(async ({ data }) => {
try {
const api = await getApi({});
await api.data.deleteOne("todos", data.id);
console.log("todo deleted from db");
} catch (error) {
console.log(error);
}
});
export const createTodo = createServerFn({ method: "POST" })
.inputValidator((data) => data as { title: string })
.handler(async ({ data }) => {
try {
const api = await getApi({});
await api.data.createOne("todos", { title: data.title });
console.log("todo created in db");
} catch (error) {
console.log(error);
}
});
export const getTodo = createServerFn({ method: "POST" }).handler(async () => {
const api = await getApi({});
const limit = 5;
const todos = await api.data.readMany("todos", { limit, sort: "-id" });
const total = todos.body.meta.total as number;
return { total, todos, limit };
});
export const Route = createFileRoute("/")({
ssr:false,
component: App,
loader: async () => {
return await getTodo();
},
});
function App() {
const { todos, total, limit } = Route.useLoaderData();
const router = useRouter();
const updateTodo = useServerFn(completeTodo);
const removeTodo = useServerFn(deleteTodo);
const addTodo = useServerFn(createTodo);
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<div className="flex flex-row items-center ">
<img
className="dark:invert size-18"
src="/tanstack-circle-logo.png"
alt="TanStack logo"
/>
<div className="ml-3.5 mr-2 font-mono opacity-70">&amp;</div>
<img
className="dark:invert"
src="/bknd.svg"
alt="bknd logo"
width={183}
height={59}
/>
</div>
<Description />
<div className="flex flex-col border border-foreground/15 w-full py-4 px-5 gap-2">
<h2 className="font-mono mb-1 opacity-70">
<code>What's next?</code>
</h2>
<div className="flex flex-col w-full gap-2">
{total > limit && (
<div className="bg-foreground/10 flex justify-center p-1 text-xs rounded text-foreground/40">
{total - limit} more todo(s) hidden
</div>
)}
<div className="flex flex-col gap-3">
{todos.map((todo) => (
<div className="flex flex-row" key={String(todo.id)}>
<div className="flex flex-row flex-grow items-center gap-3 ml-1">
<input
type="checkbox"
className="flex-shrink-0 cursor-pointer"
defaultChecked={!!todo.done}
onChange={async () => {
await updateTodo({ data: todo });
router.invalidate();
}}
/>
<div className="text-foreground/90 leading-none">
{todo.title}
</div>
</div>
<button
type="button"
className="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs "
onClick={async () => {
await removeTodo({ data: { id: todo.id } });
router.invalidate();
}}
>
</button>
</div>
))}
</div>
<form
className="flex flex-row w-full gap-3 mt-2"
key={todos.map((t) => t.id).join()}
onSubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const title = formData.get("title") as string;
await addTodo({ data: { title } });
router.invalidate();
e.currentTarget.reset();
}}
>
<input
type="text"
name="title"
placeholder="New todo"
className="py-2 px-4 flex flex-grow rounded-sm bg-foreground/10 focus:bg-foreground/20 transition-colors outline-none"
/>
<button type="submit" className="cursor-pointer">
Add
</button>
</form>
</div>
</div>
</main>
<Footer />
</div>
);
}
const Description = () => (
<List
items={[
"Get started with a full backend.",
"Focus on what matters instead of repetition.",
]}
/>
);
+124
View File
@@ -0,0 +1,124 @@
import { getApi } from "@/bknd";
import { createServerFn } from "@tanstack/react-start";
import { Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { getRequest } from "@tanstack/react-start/server";
import { Footer } from "@/components/Footer";
import { List } from "@/components/List";
export const getTodo = createServerFn({ method: "POST" }).handler(async () => {
const api = await getApi({});
const limit = 5;
const todos = await api.data.readMany("todos");
const total = todos.body.meta.total as number;
return { total, todos, limit };
});
export const getUser = createServerFn({ method: "POST" }).handler(async () => {
const request = getRequest();
const api = await getApi({ verify: true, headers: request.headers });
const user = api.getUser();
return { user };
});
export const Route = createFileRoute("/ssr")({
component: RouteComponent,
loader: async () => {
return { ...(await getTodo()), ...(await getUser()) };
},
});
function RouteComponent() {
const { todos, user } = Route.useLoaderData();
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<div className="flex flex-row items-center ">
<img
className="dark:invert size-18"
src="/tanstack-circle-logo.png"
alt="TanStack logo"
/>
<div className="ml-3.5 mr-2 font-mono opacity-70">&amp;</div>
<img
className="dark:invert"
src="/bknd.svg"
alt="bknd logo"
width={183}
height={59}
/>
</div>
<List items={todos.map((todo) => todo.title)} />
<Buttons />
<div>
{user ? (
<>
Logged in as {user.email}.{" "}
<Link
className="font-medium underline"
to={"/api/auth/logout" as string}
>
Logout
</Link>
</>
) : (
<div className="flex flex-col gap-1">
<p>
Not logged in.{" "}
<Link
className="font-medium underline"
to={"/admin/auth/login" as string}
>
Login
</Link>
</p>
<p className="text-xs opacity-50">
Sign in with:{" "}
<b>
<code>test@bknd.io</code>
</b>{" "}
/{" "}
<b>
<code>12345678</code>
</b>
</p>
</div>
)}
</div>
</main>
<Footer />
</div>
);
}
function Buttons() {
return (
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground gap-2 text-white hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://bknd.io/"
target="_blank"
rel="noopener noreferrer"
>
<img
className="grayscale"
src="/bknd.ico"
alt="bknd logomark"
width={20}
height={20}
/>
Go To Bknd.io
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://docs.bknd.io/integration/tanstack-start"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
@theme {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
body {
@apply bg-background text-foreground;
font-family: Arial, Helvetica, sans-serif;
}
+28
View File
@@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import { devtools } from "@tanstack/devtools-vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import viteTsConfigPaths from "vite-tsconfig-paths";
import { fileURLToPath, URL } from "url";
import tailwindcss from "@tailwindcss/vite";
import { nitro } from "nitro/vite";
const config = defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
plugins: [
nitro({ preset: "node-server" }),
tailwindcss(),
devtools(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tanstackStart(),
viteReact({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});
export default config;