Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b070fb591e | |||
| 81bf41ce34 | |||
| d9ab583a16 | |||
| e68df5e606 | |||
| 6304ef81f6 | |||
| 6c7e82781d | |||
| a8e5c45a22 | |||
| ee28c2ef0c | |||
| 9219bf3b3c | |||
| e437ce7120 | |||
| 2417833ed7 | |||
| ab1eddcb6a | |||
| dc8aca6a97 | |||
| 4121597fa2 | |||
| 9628720f87 | |||
| ff216ec4e5 | |||
| 06f9c3ee15 | |||
| 7751ee5db8 | |||
| feb3911d46 | |||
| ab545d3ca2 | |||
| 06c3a6f89b | |||
| 898a825f6e | |||
| a5ddf6aa93 | |||
| 5aa286298c | |||
| 55cb2bacef | |||
| e6ea3f64d7 | |||
| a5f5ea2084 | |||
| 6fb8e9a0e7 | |||
| abc6de6ad3 | |||
| 71b9a0f546 | |||
| 038ec0d672 | |||
| 7832328a50 | |||
| e5bfc602e1 | |||
| 0318e595f2 | |||
| 80b0716a4a | |||
| 3894271b17 | |||
| 8bf8b22e57 | |||
| 367d5878df | |||
| fb429e57e2 | |||
| 9008f9c6a3 | |||
| 6321a9935a | |||
| eb22289460 | |||
| c3198394de | |||
| 51237f5e1a | |||
| 01483b912f | |||
| 6288faef33 | |||
| 384e337bbd | |||
| cc1e1976f7 | |||
| 224d98879a | |||
| d1522c97ea | |||
| 8805f017a1 | |||
| 0ef06375c0 | |||
| 3d5c2cd3a8 | |||
| fc60dfd042 | |||
| c8ee30b3da | |||
| 1c114d9533 | |||
| 11e3488917 | |||
| d9bce049ef | |||
| d00e5fe68c | |||
| 6faf0d421f | |||
| 19e51fae63 | |||
| 7e01cc6101 | |||
| 103cbd11df | |||
| 09b9a5e857 | |||
| ac891a4b61 | |||
| 412eab6653 | |||
| 2fb07698d5 | |||
| 64e400c4f1 | |||
| 86d6e891a8 | |||
| 7b86611f35 | |||
| 9e316a92f6 | |||
| 12ef72d595 | |||
| 75c29ae5e2 | |||
| f0551f50d1 | |||
| 03aac3437b | |||
| f07f3d1007 | |||
| c833b2b539 | |||
| 6070394cca |
@@ -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
|
||||||
@@ -165,3 +165,19 @@ npx bknd run
|
|||||||
```bash
|
```bash
|
||||||
npm install bknd
|
npm install bknd
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) before getting started. It covers dev setup, project structure, testing, code style, and how to submit changes.
|
||||||
|
|
||||||
|
Before writing code, please open a GitHub Issue or start a conversation in [Discord](https://discord.com/invite/952SFk8Tb8) so we can align on the approach.
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
Thank you to everyone who has contributed to bknd.
|
||||||
|
|
||||||
|
<a href="https://github.com/bknd-io/bknd/graphs/contributors">
|
||||||
|
<img src="https://contrib.rocks/image?repo=bknd-io/bknd" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
Made with [contrib.rocks](https://contrib.rocks).
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ playwright-report
|
|||||||
test-results
|
test-results
|
||||||
bknd.config.*
|
bknd.config.*
|
||||||
__test__/helper.d.ts
|
__test__/helper.d.ts
|
||||||
|
.env.local
|
||||||
@@ -1,44 +1,49 @@
|
|||||||
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
|
import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test";
|
||||||
import * as adapter from "adapter";
|
import * as adapter from "adapter";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
import { omitKeys } from "core/utils";
|
|
||||||
|
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
|
||||||
|
omitKeys(cfg, ["connection"]);
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("adapter", () => {
|
describe("adapter", () => {
|
||||||
it("makes config", async () => {
|
describe("makeConfig", () => {
|
||||||
expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({});
|
it("returns empty config for empty inputs", async () => {
|
||||||
expect(
|
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
||||||
omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]),
|
[{}],
|
||||||
).toEqual({});
|
[{}, { env: { TEST: "test" } }],
|
||||||
|
];
|
||||||
|
|
||||||
// merges everything returned from `app` with the config
|
for (const args of cases) {
|
||||||
expect(
|
const cfg = await adapter.makeConfig(...(args as any));
|
||||||
omitKeys(
|
expect(stripConnection(cfg)).toEqual({});
|
||||||
await adapter.makeConfig(
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges app output into config", async () => {
|
||||||
|
const cfg = await adapter.makeConfig(
|
||||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
{ env: { TEST: "test" } },
|
{ env: { TEST: "test" } },
|
||||||
),
|
);
|
||||||
["connection"],
|
|
||||||
),
|
expect(stripConnection(cfg)).toEqual({
|
||||||
).toEqual({
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
config: { server: { cors: { origin: "test" } } },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows all properties in app function", async () => {
|
it("allows all properties in app() result", async () => {
|
||||||
const called = mock(() => null);
|
const called = mock(() => null);
|
||||||
const config = await adapter.makeConfig(
|
|
||||||
|
const cfg = await adapter.makeConfig(
|
||||||
{
|
{
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
connection: { url: "test" },
|
connection: { url: "test" },
|
||||||
config: { server: { cors: { origin: "test" } } },
|
config: { server: { cors: { origin: "test" } } },
|
||||||
options: {
|
options: { mode: "db" as const },
|
||||||
mode: "db",
|
|
||||||
},
|
|
||||||
onBuilt: () => {
|
onBuilt: () => {
|
||||||
called();
|
called();
|
||||||
expect(env).toEqual({ foo: "bar" });
|
expect(env).toEqual({ foo: "bar" });
|
||||||
@@ -47,13 +52,17 @@ describe("adapter", () => {
|
|||||||
},
|
},
|
||||||
{ foo: "bar" },
|
{ foo: "bar" },
|
||||||
);
|
);
|
||||||
expect(config.connection).toEqual({ url: "test" });
|
|
||||||
expect(config.config).toEqual({ server: { cors: { origin: "test" } } });
|
expect(cfg.connection).toEqual({ url: "test" });
|
||||||
expect(config.options).toEqual({ mode: "db" });
|
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||||
await config.onBuilt?.(null as any);
|
expect(cfg.options).toEqual({ mode: "db" });
|
||||||
expect(called).toHaveBeenCalled();
|
|
||||||
|
await cfg.onBuilt?.({} as any);
|
||||||
|
expect(called).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("adapter test suites", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createFrameworkApp,
|
makeApp: adapter.createFrameworkApp,
|
||||||
label: "framework app",
|
label: "framework app",
|
||||||
@@ -63,4 +72,5 @@ describe("adapter", () => {
|
|||||||
makeApp: adapter.createRuntimeApp,
|
makeApp: adapter.createRuntimeApp,
|
||||||
label: "runtime app",
|
label: "runtime app",
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,4 +70,10 @@ describe("Api", async () => {
|
|||||||
expect(params.token_transport).toBe("header");
|
expect(params.token_transport).toBe("header");
|
||||||
expect(params.host).toBe("http://another.com");
|
expect(params.host).toBe("http://another.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should extract tokens case insensitive", async () => {
|
||||||
|
const token = await sign({ sub: "test" }, "1234");
|
||||||
|
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
|
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,10 +110,11 @@ describe("MediaApi", () => {
|
|||||||
const api = new MediaApi({}, mockedBackend.request);
|
const api = new MediaApi({}, mockedBackend.request);
|
||||||
|
|
||||||
const name = "image.png";
|
const name = "image.png";
|
||||||
const res = await api.getFileStream(name);
|
const res = await api.getFile(name);
|
||||||
expect(isReadableStream(res)).toBe(true);
|
const stream = await api.getFileStream(name);
|
||||||
|
expect(isReadableStream(stream)).toBe(true);
|
||||||
|
|
||||||
const blob = (await new Response(res).blob()) as File;
|
const blob = (await res.res.blob()) as File;
|
||||||
expect(isFile(blob)).toBe(true);
|
expect(isFile(blob)).toBe(true);
|
||||||
expect(blob.size).toBeGreaterThan(0);
|
expect(blob.size).toBeGreaterThan(0);
|
||||||
expect(blob.type).toBe("image/png");
|
expect(blob.type).toBe("image/png");
|
||||||
|
|||||||
@@ -38,4 +38,27 @@ describe("Authenticator", async () => {
|
|||||||
expect(cookie).toStartWith("auth=");
|
expect(cookie).toStartWith("auth=");
|
||||||
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
expect(cookie).toEndWith("HttpOnly; Secure; SameSite=Strict");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("bearer token is case insensitive", async () => {
|
||||||
|
const auth = new Authenticator({}, null as any, {
|
||||||
|
jwt: {
|
||||||
|
secret: "secret",
|
||||||
|
fields: ["sub"],
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
sameSite: "strict",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const token = await auth.jwt({ sub: "test" });
|
||||||
|
|
||||||
|
const res = await auth.resolveAuthFromRequest(new Headers({
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}));
|
||||||
|
expect((res as any).sub).toBe("test")
|
||||||
|
|
||||||
|
const res2 = await auth.resolveAuthFromRequest(new Headers({
|
||||||
|
Authorization: `bearer ${token}`,
|
||||||
|
}));
|
||||||
|
expect((res2 as any).sub).toBe("test")
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ describe("postgres", () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
if (!(await isPostgresRunning())) {
|
if (!(await isPostgresRunning())) {
|
||||||
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
||||||
await $waitUntil("Postgres is running", isPostgresRunning);
|
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
disableConsoleLog();
|
disableConsoleLog();
|
||||||
});
|
}, 30000);
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
if (await isPostgresRunning()) {
|
if (await isPostgresRunning()) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
|
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "author"."id" as "id", "author"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "author"."id" asc limit ?) as obj) as "author" from "posts"',
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual([1]);
|
expect(res2.parameters).toEqual([1]);
|
||||||
});
|
});
|
||||||
@@ -192,9 +192,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ single: {} },
|
{ single: {} },
|
||||||
);
|
);
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
expect(res.sql).toBe(
|
expect(res.sql).toMatchSnapshot();
|
||||||
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
|
|
||||||
);
|
|
||||||
expect(res.parameters).toEqual(["categories.single", 1]);
|
expect(res.parameters).toEqual(["categories.single", 1]);
|
||||||
|
|
||||||
const qb2 = WithBuilder.addClause(
|
const qb2 = WithBuilder.addClause(
|
||||||
@@ -204,9 +202,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
{ multiple: {} },
|
{ multiple: {} },
|
||||||
);
|
);
|
||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toMatchSnapshot();
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'path\', "agg"."path")), \'[]\') from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as agg) as "multiple" from "categories"',
|
|
||||||
);
|
|
||||||
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
|
expect(res2.parameters).toEqual(["categories.multiple", 10, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,9 +267,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
//prettyPrintQb(qb);
|
//prettyPrintQb(qb);
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toMatchSnapshot();
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
|
|
||||||
);
|
|
||||||
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,9 +307,7 @@ describe("[data] WithBuilder", async () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toMatchSnapshot();
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
|
|
||||||
);
|
|
||||||
expect(qb.compile().parameters).toEqual([1, 12, 0]);
|
expect(qb.compile().parameters).toEqual([1, 12, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Bun Snapshot v1, https://bun.sh/docs/test/snapshots
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder recursive compiles with singles 1`] = `"select (select json_object('id', "obj"."id", 'username', "obj"."username", 'avatar', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "avatar"."id" as "id", "avatar"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "avatar"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder recursive compiles with many 1`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'posts_id', "agg"."posts_id", 'users_id', "agg"."users_id", 'users', "agg"."users")), '[]') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object('username', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder polymorphic 1`] = `"select (select json_object('id', "obj"."id", 'path', "obj"."path") from (select "single"."id" as "id", "single"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "single"."id" asc limit ?) as obj) as "single" from "categories""`;
|
||||||
|
|
||||||
|
exports[`[data] WithBuilder polymorphic 2`] = `"select (select coalesce(json_group_array(json_object('id', "agg"."id", 'path', "agg"."path")), '[]') from (select "multiple"."id" as "id", "multiple"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "multiple"."id" asc limit ? offset ?) as agg) as "multiple" from "categories""`;
|
||||||
@@ -149,7 +149,7 @@ describe("AppAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
app.registerAdminController();
|
app.registerAdminController({ forceDev: true });
|
||||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
// register custom route
|
// register custom route
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { AppReduced, type AppType } from "ui/client/utils/AppReduced";
|
||||||
|
import type { BkndAdminProps } from "ui/Admin";
|
||||||
|
|
||||||
|
// Import the normalizeAdminPath function for testing
|
||||||
|
// Note: This assumes the function is exported or we need to test it indirectly through public methods
|
||||||
|
|
||||||
|
describe("AppReduced", () => {
|
||||||
|
let mockAppJson: AppType;
|
||||||
|
let appReduced: AppReduced;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAppJson = {
|
||||||
|
data: {
|
||||||
|
entities: {},
|
||||||
|
relations: {},
|
||||||
|
},
|
||||||
|
flows: {
|
||||||
|
flows: {},
|
||||||
|
},
|
||||||
|
auth: {},
|
||||||
|
} as AppType;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getSettingsPath", () => {
|
||||||
|
it("should return settings path with basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return settings path with empty basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should append additional path segments", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["user", "profile"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/user/profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize multiple slashes", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "//admin//",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["//user//"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/user");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle basepath without leading slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAbsolutePath", () => {
|
||||||
|
it("should return absolute path with basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("dashboard");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return base path when no path provided", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath();
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize paths correctly", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "//admin//",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("//dashboard//");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("options getter", () => {
|
||||||
|
it("should return merged options with defaults", () => {
|
||||||
|
const customOptions: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/custom-admin",
|
||||||
|
logo_return_path: "/custom-home",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, customOptions);
|
||||||
|
const options = appReduced.options;
|
||||||
|
|
||||||
|
expect(options).toEqual({
|
||||||
|
logo_return_path: "/custom-home",
|
||||||
|
basepath: "/custom-admin",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default logo_return_path when not provided", () => {
|
||||||
|
const customOptions: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, customOptions);
|
||||||
|
const options = appReduced.options;
|
||||||
|
|
||||||
|
expect(options.logo_return_path).toBe("/");
|
||||||
|
expect(options.basepath).toBe("/admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path normalization behavior", () => {
|
||||||
|
it("should normalize duplicate slashes in settings path", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["//nested//path//"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/nested/path");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle root path normalization", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath();
|
||||||
|
|
||||||
|
// The normalizeAdminPath function removes trailing slashes except for root "/"
|
||||||
|
// When basepath is "/", the result is "~/" which becomes "~" after normalization
|
||||||
|
expect(result).toBe("~");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve entity paths ending with slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("entity/");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/entity");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove trailing slashes from non-entity paths", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("dashboard/");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/dashboard");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withBasePath - double slash fix (admin_basepath with trailing slash)", () => {
|
||||||
|
it("should not produce double slashes when admin_basepath has trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.withBasePath("/data");
|
||||||
|
|
||||||
|
expect(result).toBe("/admin/data");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work correctly when admin_basepath has no trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.withBasePath("/data");
|
||||||
|
|
||||||
|
expect(result).toBe("/admin/data");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle absolute paths with admin_basepath trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getAbsolutePath("data");
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/data");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle settings path with admin_basepath trailing slash", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["general"]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/general");
|
||||||
|
expect(result).not.toContain("//");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle undefined basepath", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath();
|
||||||
|
|
||||||
|
// When basepath is undefined, it defaults to empty string
|
||||||
|
expect(result).toBe("~/settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle null path segments", () => {
|
||||||
|
const options: BkndAdminProps["config"] = {
|
||||||
|
basepath: "/admin",
|
||||||
|
logo_return_path: "/",
|
||||||
|
};
|
||||||
|
|
||||||
|
appReduced = new AppReduced(mockAppJson, options);
|
||||||
|
const result = appReduced.getSettingsPath(["", "valid", ""]);
|
||||||
|
|
||||||
|
expect(result).toBe("~/admin/settings/valid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { $ } from "bun";
|
|||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import pkg from "./package.json" with { type: "json" };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
import { watch as fsWatch } from "node:fs";
|
import { watch as fsWatch, readdirSync, rmSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
@@ -27,7 +27,18 @@ const define = {
|
|||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.info("Cleaning dist (w/o static)");
|
console.info("Cleaning dist (w/o static)");
|
||||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
// Cross-platform clean: remove all files/folders in dist except static
|
||||||
|
const distPath = join(import.meta.dir, "dist");
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(distPath);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === "static") continue;
|
||||||
|
const entryPath = join(distPath, entry);
|
||||||
|
rmSync(entryPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// dist may not exist yet, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let types_running = false;
|
let types_running = false;
|
||||||
@@ -114,7 +125,10 @@ async function buildApi() {
|
|||||||
|
|
||||||
async function rewriteClient(path: string) {
|
async function rewriteClient(path: string) {
|
||||||
const bundle = await Bun.file(path).text();
|
const bundle = await Bun.file(path).text();
|
||||||
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
await Bun.write(
|
||||||
|
path,
|
||||||
|
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,7 +238,10 @@ async function buildUiElements() {
|
|||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
function baseConfig(
|
||||||
|
adapter: string,
|
||||||
|
overrides: Partial<tsup.Options> = {},
|
||||||
|
): tsup.Options {
|
||||||
return {
|
return {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
@@ -308,11 +325,21 @@ async function buildAdapters() {
|
|||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("tanstack-start"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sveltekit"),
|
...baseConfig("sveltekit"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("nuxt"),
|
||||||
|
platform: "node",
|
||||||
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("node"),
|
...baseConfig("node"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
@@ -340,11 +367,17 @@ async function buildAdapters() {
|
|||||||
metafile: false,
|
metafile: false,
|
||||||
external: [/^bun\:.*/],
|
external: [/^bun\:.*/],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAll() {
|
async function buildAll() {
|
||||||
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
await Promise.all([
|
||||||
|
buildApi(),
|
||||||
|
buildUi(),
|
||||||
|
buildUiElements(),
|
||||||
|
buildAdapters(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial build
|
// initial build
|
||||||
@@ -376,14 +409,22 @@ if (watch) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// watch src directory recursively
|
// watch src directory recursively
|
||||||
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
|
fsWatch(
|
||||||
|
join(import.meta.dir, "src"),
|
||||||
|
{ recursive: true },
|
||||||
|
(event, filename) => {
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
// ignore non-source files
|
// ignore non-source files
|
||||||
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
|
if (
|
||||||
|
!filename.endsWith(".ts") &&
|
||||||
|
!filename.endsWith(".tsx") &&
|
||||||
|
!filename.endsWith(".css")
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||||
debouncedRebuild();
|
debouncedRebuild();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// keep process alive
|
// keep process alive
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import path from "node:path";
|
|||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
|
||||||
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
|
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
cmd: string[] | string,
|
cmd: string[] | string,
|
||||||
opts: Bun.SpawnOptions.OptionsObject & {},
|
opts: RunOptions,
|
||||||
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
||||||
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -17,7 +18,7 @@ async function run(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Read from stdout
|
// Read from stdout
|
||||||
const reader = (proc.stdout as ReadableStream).getReader();
|
const reader = proc.stdout.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
// Function to read chunks
|
// Function to read chunks
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const configs = {
|
|||||||
nextjs: {
|
nextjs: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
|
nuxt: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
astro: {
|
astro: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
@@ -24,6 +27,9 @@ const configs = {
|
|||||||
bun: {
|
bun: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
|
"tanstack-start": {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAdapterConfig(): typeof default_config {
|
export function getAdapterConfig(): typeof default_config {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.20.0",
|
"version": "0.21.0-rc.1",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"@xyflow/react": "^12.9.2",
|
"@xyflow/react": "^12.9.2",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"fast-xml-parser": "^5.3.1",
|
"fast-xml-parser": "^5.3.1",
|
||||||
"hono": "4.10.4",
|
"hono": "4.10.4",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
@@ -233,6 +233,11 @@
|
|||||||
"import": "./dist/adapter/nextjs/index.js",
|
"import": "./dist/adapter/nextjs/index.js",
|
||||||
"require": "./dist/adapter/nextjs/index.js"
|
"require": "./dist/adapter/nextjs/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/nuxt": {
|
||||||
|
"types": "./dist/types/adapter/nuxt/index.d.ts",
|
||||||
|
"import": "./dist/adapter/nuxt/index.js",
|
||||||
|
"require": "./dist/adapter/nuxt/index.js"
|
||||||
|
},
|
||||||
"./adapter/react-router": {
|
"./adapter/react-router": {
|
||||||
"types": "./dist/types/adapter/react-router/index.d.ts",
|
"types": "./dist/types/adapter/react-router/index.d.ts",
|
||||||
"import": "./dist/adapter/react-router/index.js",
|
"import": "./dist/adapter/react-router/index.js",
|
||||||
@@ -268,6 +273,11 @@
|
|||||||
"import": "./dist/adapter/browser/index.js",
|
"import": "./dist/adapter/browser/index.js",
|
||||||
"require": "./dist/adapter/browser/index.js"
|
"require": "./dist/adapter/browser/index.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/tanstack-start": {
|
||||||
|
"types": "./dist/types/adapter/tanstack-start/index.d.ts",
|
||||||
|
"import": "./dist/adapter/tanstack-start/index.js",
|
||||||
|
"require": "./dist/adapter/tanstack-start/index.js"
|
||||||
|
},
|
||||||
"./dist/main.css": "./dist/ui/main.css",
|
"./dist/main.css": "./dist/ui/main.css",
|
||||||
"./dist/styles.css": "./dist/ui/styles.css",
|
"./dist/styles.css": "./dist/ui/styles.css",
|
||||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
||||||
@@ -282,10 +292,12 @@
|
|||||||
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
||||||
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
||||||
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
||||||
|
"adapter/nuxt": ["./dist/types/adapter/nuxt/index.d.ts"],
|
||||||
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||||
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||||
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||||
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
"adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"],
|
||||||
|
"adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"],
|
||||||
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -312,6 +324,7 @@
|
|||||||
"serverless",
|
"serverless",
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
"nextjs",
|
"nextjs",
|
||||||
|
"nuxt",
|
||||||
"remix",
|
"remix",
|
||||||
"react-router",
|
"react-router",
|
||||||
"astro",
|
"astro",
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// try authorization header
|
// try authorization header
|
||||||
const headerToken = this.options.headers.get("authorization")?.replace("Bearer ", "");
|
const headerToken = this.options.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
||||||
if (headerToken) {
|
if (headerToken) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./nuxt.adapter";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as nuxt from "./nuxt.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("nuxt adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: nuxt.getApp,
|
||||||
|
makeHandler: nuxt.serve,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||||
|
|
||||||
|
export type NuxtEnv = NodeJS.ProcessEnv;
|
||||||
|
export type NuxtBkndConfig<Env = NuxtEnv> = RuntimeBkndConfig<Env>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bknd app instance
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export async function getApp<Env>(
|
||||||
|
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
|
return await createRuntimeApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create middleware handler for Nuxt
|
||||||
|
* @param config - bknd configuration
|
||||||
|
* @param args - environment variables
|
||||||
|
*/
|
||||||
|
export function serve<Env>(
|
||||||
|
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
||||||
|
args: Env,
|
||||||
|
) {
|
||||||
|
return async (request: Request) => {
|
||||||
|
return (await getApp(config, args)).fetch(request);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -199,7 +199,12 @@ export class AuthController extends Controller {
|
|||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
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);
|
this.registerStrategyActions(strategy, hono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +310,9 @@ export class AuthController extends Controller {
|
|||||||
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
|
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
|
||||||
|
|
||||||
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
|
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(
|
const res = await controller.request(
|
||||||
new Request("https://localhost/login", {
|
new Request("https://localhost/login", {
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ export class Authenticator<
|
|||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
if (headers.has("Authorization")) {
|
if (headers.has("Authorization")) {
|
||||||
const bearerHeader = String(headers.get("Authorization"));
|
const bearerHeader = String(headers.get("Authorization"));
|
||||||
token = bearerHeader.replace("Bearer ", "");
|
token = bearerHeader.replace(/^Bearer\s+/i, "");
|
||||||
} else {
|
} else {
|
||||||
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
|
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
|
||||||
token = await this.getAuthCookie(context);
|
token = await this.getAuthCookie(context);
|
||||||
|
|||||||
@@ -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 hono = new Hono();
|
||||||
const redirectQuerySchema = s.object({
|
const redirectQuerySchema = s.object({
|
||||||
redirect: s.string().optional(),
|
redirect: s.string().optional(),
|
||||||
@@ -120,6 +120,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (opts.allow_register) {
|
||||||
hono.post(
|
hono.post(
|
||||||
"/register",
|
"/register",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
@@ -155,6 +156,7 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export abstract class AuthStrategy<Schema extends s.Schema = s.Schema> {
|
|||||||
|
|
||||||
protected abstract getSchema(): Schema;
|
protected abstract getSchema(): Schema;
|
||||||
|
|
||||||
abstract getController(auth: Authenticator): Hono;
|
abstract getController(auth: Authenticator, opts: { allow_register?: boolean }): Hono;
|
||||||
|
|
||||||
getType(): string {
|
getType(): string {
|
||||||
return this.type;
|
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 hono = new Hono();
|
||||||
const secret = "secret";
|
const secret = "secret";
|
||||||
const cookie_name = "_challenge";
|
const cookie_name = "_challenge";
|
||||||
@@ -379,6 +379,10 @@ export class OAuthStrategy extends AuthStrategy<typeof schemaProvided> {
|
|||||||
return c.notFound();
|
return c.notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "register" && !opts.allow_register) {
|
||||||
|
return c.notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const path = url.pathname.replace(`/${action}`, "");
|
const path = url.pathname.replace(`/${action}`, "");
|
||||||
const redirect_uri = url.origin + path + "/callback";
|
const redirect_uri = url.origin + path + "/callback";
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ export const sync: CliCommand = (program) => {
|
|||||||
console.info("");
|
console.info("");
|
||||||
if (stmts.length === 0) {
|
if (stmts.length === 0) {
|
||||||
console.info(c.yellow("No changes to sync"));
|
console.info(c.yellow("No changes to sync"));
|
||||||
process.exit(0);
|
} else {
|
||||||
}
|
|
||||||
// @todo: currently assuming parameters aren't used
|
// @todo: currently assuming parameters aren't used
|
||||||
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
|
||||||
|
|
||||||
@@ -32,20 +31,6 @@ export const sync: CliCommand = (program) => {
|
|||||||
|
|
||||||
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
|
||||||
console.info(`${c.green("Database synced")}`);
|
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"));
|
|
||||||
} else {
|
|
||||||
console.info(c.yellow("No seed function provided"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (options.out) {
|
if (options.out) {
|
||||||
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
|
||||||
@@ -61,6 +46,22 @@ export const sync: CliCommand = (program) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export class WithBuilder {
|
|||||||
if (query) {
|
if (query) {
|
||||||
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
||||||
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
||||||
|
alias: other.reference,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { css, Style } from "hono/css";
|
|||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import type { TApiUser } from "Api";
|
import type { TApiUser } from "Api";
|
||||||
|
import type { AppTheme } from "ui/client/use-theme";
|
||||||
import type { Manifest } from "vite";
|
import type { Manifest } from "vite";
|
||||||
|
|
||||||
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||||
@@ -20,7 +21,7 @@ export type AdminBkndWindowContext = {
|
|||||||
logout_route: string;
|
logout_route: string;
|
||||||
admin_basepath: string;
|
admin_basepath: string;
|
||||||
logo_return_path?: string;
|
logo_return_path?: string;
|
||||||
theme?: "dark" | "light" | "system";
|
theme?: AppTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
// @todo: add migration to remove admin path from config
|
// @todo: add migration to remove admin path from config
|
||||||
@@ -31,7 +32,7 @@ export type AdminControllerOptions = {
|
|||||||
html?: string;
|
html?: string;
|
||||||
forceDev?: boolean | { mainPath: string };
|
forceDev?: boolean | { mainPath: string };
|
||||||
debugRerenders?: boolean;
|
debugRerenders?: boolean;
|
||||||
theme?: "dark" | "light" | "system";
|
theme?: AppTheme;
|
||||||
logoReturnPath?: string;
|
logoReturnPath?: string;
|
||||||
manifest?: Manifest;
|
manifest?: Manifest;
|
||||||
};
|
};
|
||||||
@@ -122,7 +123,7 @@ export class AdminController extends Controller {
|
|||||||
const obj: AdminBkndWindowContext = {
|
const obj: AdminBkndWindowContext = {
|
||||||
user: c.get("auth")?.user,
|
user: c.get("auth")?.user,
|
||||||
logout_route: authRoutes.logout,
|
logout_route: authRoutes.logout,
|
||||||
admin_basepath: this.options.adminBasepath,
|
admin_basepath: this.options.adminBasepath.replace(/\/+$/, ""),
|
||||||
theme: this.options.theme,
|
theme: this.options.theme,
|
||||||
logo_return_path: this.options.logoReturnPath,
|
logo_return_path: this.options.logoReturnPath,
|
||||||
};
|
};
|
||||||
@@ -209,8 +210,9 @@ export class AdminController extends Controller {
|
|||||||
},
|
},
|
||||||
}).then((res) => res.json());
|
}).then((res) => res.json());
|
||||||
} else {
|
} else {
|
||||||
|
const manifestPath = "bknd/dist/manifest.json";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
manifest = await import("bknd/dist/manifest.json", {
|
manifest = await import(/* @vite-ignore */ manifestPath, {
|
||||||
with: { type: "json" },
|
with: { type: "json" },
|
||||||
}).then((res) => res.default);
|
}).then((res) => res.default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,4 +71,54 @@ describe("timestamps plugin", () => {
|
|||||||
expect(updatedData.updated_at).toBeDefined();
|
expect(updatedData.updated_at).toBeDefined();
|
||||||
expect(updatedData.updated_at).toBeInstanceOf(Date);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { $console } from "bknd/utils";
|
|||||||
export type TimestampsPluginOptions = {
|
export type TimestampsPluginOptions = {
|
||||||
entities: string[];
|
entities: string[];
|
||||||
setUpdatedOnCreate?: boolean;
|
setUpdatedOnCreate?: boolean;
|
||||||
|
indexStrategy?: "composite" | "individual";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,7 @@ export type TimestampsPluginOptions = {
|
|||||||
export function timestamps({
|
export function timestamps({
|
||||||
entities = [],
|
entities = [],
|
||||||
setUpdatedOnCreate = true,
|
setUpdatedOnCreate = true,
|
||||||
|
indexStrategy,
|
||||||
}: TimestampsPluginOptions): AppPlugin {
|
}: TimestampsPluginOptions): AppPlugin {
|
||||||
return (app: App) => ({
|
return (app: App) => ({
|
||||||
name: "timestamps",
|
name: "timestamps",
|
||||||
@@ -29,12 +31,11 @@ export function timestamps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appEntities = app.em.entities.map((e) => e.name);
|
const appEntities = app.em.entities.map((e) => e.name);
|
||||||
|
const actualEntities = entities.filter((e) => appEntities.includes(e));
|
||||||
|
|
||||||
return em(
|
return em(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
entities
|
actualEntities.map((e) => [
|
||||||
.filter((e) => appEntities.includes(e))
|
|
||||||
.map((e) => [
|
|
||||||
e,
|
e,
|
||||||
entity(e, {
|
entity(e, {
|
||||||
created_at: datetime(),
|
created_at: datetime(),
|
||||||
@@ -42,6 +43,23 @@ export function timestamps({
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
(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 () => {
|
onBuilt: async () => {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export class AppReduced {
|
|||||||
protected appJson: AppType,
|
protected appJson: AppType,
|
||||||
protected _options: BkndAdminProps["config"] = {},
|
protected _options: BkndAdminProps["config"] = {},
|
||||||
) {
|
) {
|
||||||
//console.log("received appjson", appJson);
|
|
||||||
|
|
||||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||||
return constructEntity(name, entity);
|
return constructEntity(name, entity);
|
||||||
@@ -70,20 +69,27 @@ export class AppReduced {
|
|||||||
|
|
||||||
get options() {
|
get options() {
|
||||||
return {
|
return {
|
||||||
basepath: "",
|
basepath: "/",
|
||||||
logo_return_path: "/",
|
logo_return_path: "/",
|
||||||
...this._options,
|
...this._options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withBasePath(path: string | string[], absolute = false): string {
|
||||||
|
const paths = Array.isArray(path) ? path : [path];
|
||||||
|
return [absolute ? "~" : null, this.options.basepath, ...paths]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("/")
|
||||||
|
.replace(/\/+/g, "/")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
getSettingsPath(path: string[] = []): string {
|
getSettingsPath(path: string[] = []): string {
|
||||||
const base = `~/${this.options.basepath}/settings`.replace(/\/+/g, "/");
|
return this.withBasePath(["settings", ...path], true);
|
||||||
return [base, ...path].join("/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAbsolutePath(path?: string): string {
|
getAbsolutePath(path?: string): string {
|
||||||
const { basepath } = this.options;
|
return this.withBasePath(path ?? [], true);
|
||||||
return (path ? `~/${basepath}/${path}` : `~/${basepath}`).replace(/\/+/g, "/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthConfig() {
|
getAuthConfig() {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function Routes({
|
|||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const ctx = useBkndWindowContext();
|
const ctx = useBkndWindowContext();
|
||||||
const actualBasePath = basePath || ctx.admin_basepath;
|
const actualBasePath = (basePath || ctx.admin_basepath).replace(/\/+$/, "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="bknd-admin" className={theme + " antialiased"}>
|
<div id="bknd-admin" className={theme + " antialiased"}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"configVersion": 0,
|
"configVersion": 1,
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bknd",
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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",
|
theme: "dark",
|
||||||
logo_return_path: "/../"
|
logo_return_path: "/../"
|
||||||
}}
|
}}
|
||||||
client:only
|
client:only="react"
|
||||||
/>
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
{
|
{
|
||||||
"pages": ["nextjs", "react-router", "astro", "sveltekit", "vite"]
|
"pages": [
|
||||||
|
"nextjs",
|
||||||
|
"react-router",
|
||||||
|
"astro",
|
||||||
|
"sveltekit",
|
||||||
|
"tanstack-start",
|
||||||
|
"vite",
|
||||||
|
"nuxt"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,402 @@
|
|||||||
|
---
|
||||||
|
title: "Nuxt"
|
||||||
|
description: "Run bknd inside Nuxt"
|
||||||
|
tags: ["documentation"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To get started with Nuxt and bknd, create a new Nuxt project by following the [official guide](https://nuxt.com/docs/4.x/getting-started/installation), 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 NuxtBkndConfig from "bknd/adapter/nuxt";
|
||||||
|
import { em, entity, text, boolean } from "bknd";
|
||||||
|
import { secureRandomString } from "bknd/utils";
|
||||||
|
|
||||||
|
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: secureRandomString(32),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 NuxtBkndConfig;
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `NuxtBkndConfig` type extends the base config type with the following properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type NuxtBkndConfig<Env = NuxtEnv> = FrameworkBkndConfig<Env>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serve the API and Admin UI
|
||||||
|
|
||||||
|
The Nuxt adapter uses Nuxt middleware to handle API requests and serve the Admin UI. Create a `/server/middleware/bknd.ts` file:
|
||||||
|
|
||||||
|
```typescript title="/server/middleware/bknd.ts"
|
||||||
|
import { serve } from "bknd/adapter/nuxt";
|
||||||
|
import config from "../../bknd.config";
|
||||||
|
|
||||||
|
const handler = serve(config, process.env);
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const pathname = event.path;
|
||||||
|
const request = toWebRequest(event);
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api") || pathname !== "/") {
|
||||||
|
const res = await handle(request);
|
||||||
|
|
||||||
|
if (res && res.status !== 404) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="success">
|
||||||
|
You can visit https://localhost:3000/admin to see the admin UI. Additionally you can create more todos as you explore the admin UI.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
|
||||||
|
|
||||||
|
```ts title="server/utils/bknd.ts"
|
||||||
|
import { type NuxtBkndConfig, getApp as getNuxtApp } from "bknd/adapter/nuxt";
|
||||||
|
import bkndConfig from "../../bknd.config";
|
||||||
|
|
||||||
|
export async function getApp<Env = NodeJS.ProcessEnv>(
|
||||||
|
config: NuxtBkndConfig<Env>,
|
||||||
|
args: Env = process.env as Env,
|
||||||
|
) {
|
||||||
|
return await getNuxtApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApi({ headers, verify }: { verify?: boolean; headers?: Headers }) {
|
||||||
|
const app = await getApp(bkndConfig, 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 Nuxt uses Nitro underneath and it will use polyfills for `process.env` making it platform/runtime agnostic.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
|
||||||
|
## Example usage of the API
|
||||||
|
|
||||||
|
You can use the `getApp` function to access the bknd API in your app to expose endpoints,
|
||||||
|
Here are some examples:
|
||||||
|
|
||||||
|
```typescript title="server/routes/todos.post.ts"
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { data, action } = body;
|
||||||
|
|
||||||
|
const api = await getApi({});
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'get': {
|
||||||
|
const limit = 5;
|
||||||
|
const todos = await api.data.readMany("todos", { limit, sort: "-id" });
|
||||||
|
return { total: todos.body.meta.total, todos, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create': {
|
||||||
|
return await api.data.createOne("todos", { title: data.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
return await api.data.deleteOne("todos", data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toggle': {
|
||||||
|
return await api.data.updateOne("todos", data.id, { done: !data.done });
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return { path: action };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="warning">
|
||||||
|
This can't be done in the `server/api` directory as it will collide with the API endpoints created by the middleware. We will use [defineEventHandler](https://nuxt.com/docs/4.x/directory-structure/server) in the [server/routes](https://nuxt.com/docs/4.x/directory-structure/server) directory to create endpoints and use them in conjunction with composables to access the API safely.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
|
||||||
|
### Using the API through composables
|
||||||
|
|
||||||
|
To use the API in your frontend components/pages, you can create a composable that uses the enpoints created in previous steps:
|
||||||
|
|
||||||
|
```typescript title="app/composables/useTodoActions.ts"
|
||||||
|
import type { DB } from "bknd";
|
||||||
|
|
||||||
|
type Todo = DB["todos"];
|
||||||
|
|
||||||
|
export const useTodoActions = () => {
|
||||||
|
const fetchTodos = () =>
|
||||||
|
$fetch<{ limit: number; todos: Array<Todo>; total: number }>("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "get" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTodo = (title: string) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "create", data: { title } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTodo = (todo: Todo) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "delete", data: { id: todo.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTodo = (todo: Todo) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "toggle", data: todo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fetchTodos, createTodo, deleteTodo, toggleTodo };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in a page/component
|
||||||
|
|
||||||
|
Make a composable to fetch the todos:
|
||||||
|
|
||||||
|
```ts title="app/composables/useTodoActions.ts"
|
||||||
|
import type { DB } from "bknd";
|
||||||
|
|
||||||
|
type Todo = DB["todos"];
|
||||||
|
|
||||||
|
export const useTodoActions = () => {
|
||||||
|
const fetchTodos = () =>
|
||||||
|
$fetch<{ limit: number; todos: Array<Todo>; total: number }>("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "get" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTodo = (title: string) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "create", data: { title } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTodo = (todo: Todo) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "delete", data: { id: todo.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTodo = (todo: Todo) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "toggle", data: todo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fetchTodos, createTodo, deleteTodo, toggleTodo };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
Then use the `useTodoActions` composable in a page:
|
||||||
|
|
||||||
|
```vue title="app/pages/todos.vue"
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const { fetchTodos, createTodo, deleteTodo, toggleTodo } = useTodoActions();
|
||||||
|
const { data:todos, execute } = await useAsyncData("todos", () => fetchTodos());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
execute();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="todos"
|
||||||
|
className="flex flex-col items-center justify-center min-h-screen p-8 pb-20 gap-16 sm:p-20"
|
||||||
|
>
|
||||||
|
<main className="flex flex-col gap-8 row-start-2 justify-center items-center sm:items-start">
|
||||||
|
<div class="flex flex-row items-center ">
|
||||||
|
<img class="dark:invert size-24" src="/nuxt.svg" alt="Nuxt logo" />
|
||||||
|
<div class="ml-3.5 mr-2 font-mono opacity-70">&</div>
|
||||||
|
<img class="dark:invert" src="/bknd.svg" alt="bknd logo" width="183" height="59" />
|
||||||
|
</div>
|
||||||
|
<div v-if="data?.todos">
|
||||||
|
<ul>
|
||||||
|
<li v-for="todo in data.todos" :key="todo.id">
|
||||||
|
{{ todo.title }}
|
||||||
|
<button @click="toggleTodo(todo)">Toggle</button>
|
||||||
|
<button @click="deleteTodo(todo)">Delete</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<form @submit.prevent="createTodo('New Todo')">
|
||||||
|
<input type="text" placeholder="New Todo" />
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-else className="flex flex-col gap-1">
|
||||||
|
<p>
|
||||||
|
No todos found.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="success">
|
||||||
|
You can visit https://localhost:3000/todos to see all the todos.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
### Using authentication
|
||||||
|
|
||||||
|
Make a composable to fetch the user:
|
||||||
|
|
||||||
|
```ts title="app/composables/useUser.ts"
|
||||||
|
import type { User } from "bknd";
|
||||||
|
|
||||||
|
export const useUser = () => {
|
||||||
|
const getUser = () => $fetch("/api/auth/me") as Promise<{ user: User }>;
|
||||||
|
return { getUser };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use the `useUser` composable in a page:
|
||||||
|
|
||||||
|
```vue title="app/pages/user.vue"
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const { getUser } = useUser();
|
||||||
|
const { data, status: userStatus, execute } = await useAsyncData("user", () => getUser());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="userStatus !== 'pending'"
|
||||||
|
className="flex flex-col items-center justify-center min-h-screen p-8 pb-20 gap-16 sm:p-20"
|
||||||
|
>
|
||||||
|
<main className="flex flex-col gap-8 row-start-2 justify-center items-center sm:items-start">
|
||||||
|
<div class="flex flex-row items-center ">
|
||||||
|
<img class="dark:invert size-24" src="/nuxt.svg" alt="Nuxt logo" />
|
||||||
|
<div class="ml-3.5 mr-2 font-mono opacity-70">&</div>
|
||||||
|
<img class="dark:invert" src="/bknd.svg" alt="bknd logo" width="183" height="59" />
|
||||||
|
</div>
|
||||||
|
<div v-if="data?.user">
|
||||||
|
Logged in as {{ data.user.email }}.
|
||||||
|
<a className="font-medium underline" href='/api/auth/logout'>
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-else className="flex flex-col gap-1">
|
||||||
|
<p>
|
||||||
|
Not logged in.
|
||||||
|
<a className="font-medium underline" href="/admin/auth/login">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs opacity-50">
|
||||||
|
Sign in with:
|
||||||
|
<b>
|
||||||
|
<code>test@bknd.io</code>
|
||||||
|
</b>
|
||||||
|
/
|
||||||
|
<b>
|
||||||
|
<code>12345678</code>
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Note
|
||||||
|
|
||||||
|
Use `external` attribute on Nuxt links, anytime you are traversing to an external route (like `/api/*` which are handled by bknd's middleware) to prevent vue router from intercepting the link.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<NuxtLink external href="/admin">
|
||||||
|
Admin
|
||||||
|
</NuxtLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
<Callout type="error">
|
||||||
|
If you don't use the `external` attribute, vue router will intercept the link and try to navigate to it, which will fail and result in a 404 error.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
Check the [Nuxt repository example](https://github.com/bknd-io/bknd/tree/main/examples/nuxt) for more implementation details.
|
||||||
@@ -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,18 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you
|
|||||||
href="/integration/sveltekit"
|
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="simple-icons:nuxt" className="text-fd-primary !size-6" />}
|
||||||
|
title="Nuxt"
|
||||||
|
href="/integration/nuxt"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
|
<Card title="Yours missing?" href="https://github.com/bknd-io/bknd/issues/new">
|
||||||
Create a new issue to request a guide for your framework.
|
Create a new issue to request a guide for your framework.
|
||||||
</Card>
|
</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
|
## Securing Your Admin Portal
|
||||||
|
|
||||||
<Callout type="warn" title="Do not enable the Guard without an admin user">
|
<Callout type="warn" title="Do not enable the Guard without an admin user">
|
||||||
|
|||||||
@@ -150,6 +150,18 @@ Pick your framework or runtime to get started.
|
|||||||
href="/integration/sveltekit"
|
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="simple-icons:nuxt" className="text-fd-primary !size-6" />}
|
||||||
|
title="Nuxt"
|
||||||
|
href="/integration/nuxt"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
|
icon={<Icon icon="tabler:lambda" className="text-fd-primary !size-6" />}
|
||||||
title="AWS Lambda"
|
title="AWS Lambda"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { type BunBkndConfig, serve } from "bknd/adapter/bun";
|
|||||||
// this is optional, if omitted, it uses an in-memory database
|
// this is optional, if omitted, it uses an in-memory database
|
||||||
const config: BunBkndConfig = {
|
const config: BunBkndConfig = {
|
||||||
connection: {
|
connection: {
|
||||||
url: "file:data.db",
|
url: "data.db",
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { serve } from "bknd/adapter/node";
|
|||||||
/** @type {import("bknd/adapter/node").NodeBkndConfig} */
|
/** @type {import("bknd/adapter/node").NodeBkndConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
connection: {
|
connection: {
|
||||||
url: "file:data.db",
|
url: "data.db",
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
media: {
|
media: {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
public/admin
|
||||||
|
data.db
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# bknd starter: Nuxt
|
||||||
|
A minimal example of a Nuxt project with bknd integration.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Inside of your Nuxt project, you'll see the following folders and files:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── app
|
||||||
|
│ ├── assets
|
||||||
|
│ │ └── css
|
||||||
|
│ ├── components
|
||||||
|
│ │ ├── Buttons.vue
|
||||||
|
│ │ ├── Footer.vue
|
||||||
|
│ │ └── List.vue
|
||||||
|
│ ├── composables
|
||||||
|
│ │ ├── useTodoActions.ts
|
||||||
|
│ │ └── useUser.ts
|
||||||
|
│ └── pages
|
||||||
|
│ ├── index.vue
|
||||||
|
│ └── user.vue
|
||||||
|
├── bknd.config.ts
|
||||||
|
├── bun.lock
|
||||||
|
├── nuxt.config.ts
|
||||||
|
├── package.json
|
||||||
|
├── public
|
||||||
|
│ ├── admin # generated by bknd and contains the admin UI
|
||||||
|
│ ├── bknd.ico
|
||||||
|
│ ├── bknd.svg
|
||||||
|
│ ├── favicon.ico
|
||||||
|
│ ├── file.svg
|
||||||
|
│ ├── globe.svg
|
||||||
|
│ ├── nuxt.svg
|
||||||
|
│ ├── robots.txt
|
||||||
|
│ └── window.svg
|
||||||
|
├── README.md
|
||||||
|
├── server
|
||||||
|
│ ├── middleware
|
||||||
|
│ │ └── bknd.ts # intercepts api and admin ui requests
|
||||||
|
│ ├── routes
|
||||||
|
│ │ └── todos.post.ts
|
||||||
|
│ └── utils
|
||||||
|
│ └── bknd.ts # initializes bknd instance
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Here is a quick overview about how to adjust the behavior of `bknd`:
|
||||||
|
* Initialization of the `bknd` config with helper functions are located at `src/server/utils/bknd.ts`
|
||||||
|
* Admin UI is rendered at `src/server/middleware/bknd.ts`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All commands are run from the root of the project, from a terminal:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|:--------------------------|:-------------------------------------------------|
|
||||||
|
| `npm install` | Installs dependencies |
|
||||||
|
| `npm run dev` | Starts local dev server at `localhost:3000` |
|
||||||
|
| `npm run build` | Build your production site |
|
||||||
|
|
||||||
|
## Want to learn more?
|
||||||
|
|
||||||
|
Feel free to check [our documentation](https://docs.bknd.io/integration/nuxt) or jump into our [Discord server](https://discord.gg/952SFk8Tb8).
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&display=swap");
|
||||||
|
|
||||||
|
.geist-mono-100 {
|
||||||
|
font-optical-sizing: auto;
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
font-family: "Geist Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-geist-mono-100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<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/nextjs" target="_blank" rel="noopener noreferrer">
|
||||||
|
Read our docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const pathname = computed(() => route.path)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||||
|
<NuxtLink class="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||||
|
:to="pathname === '/' ? '/user' : '/'">
|
||||||
|
<img aria-hidden src="/file.svg" alt="File icon" width="16" height="16" />
|
||||||
|
{{ pathname === '/' ? 'User' : 'Home' }}
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- external is attribute required to hit the trigger middleware -->
|
||||||
|
<NuxtLink external class="flex items-center gap-2 hover:underline hover:underline-offset-4" href="/admin/data">
|
||||||
|
<img aria-hidden src="/window.svg" alt="Window icon" width="16" height="16" />
|
||||||
|
Admin
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<a class="flex items-center gap-2 hover:underline hover:underline-offset-4" href="https://bknd.io" target="_blank"
|
||||||
|
rel="noopener noreferrer">
|
||||||
|
<img aria-hidden src="/globe.svg" alt="Globe icon" width="16" height="16" />
|
||||||
|
Go to bknd.io →
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const props = withDefaults(defineProps<{ items?: any[] }>(), { items: () => [] })
|
||||||
|
|
||||||
|
function isPrimitive(val: unknown): boolean {
|
||||||
|
const t = typeof val
|
||||||
|
return t === 'string' || t === 'number' || t === 'boolean'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ol class="list-inside list-decimal text-sm text-center sm:text-left w-full text-center">
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in props.items"
|
||||||
|
:key="i"
|
||||||
|
:class="{ 'mb-2': i < props.items.length - 1 }"
|
||||||
|
>
|
||||||
|
<span v-if="isPrimitive(item)">{{ item }}</span>
|
||||||
|
<component v-else :is="item" />
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { DB } from "bknd";
|
||||||
|
|
||||||
|
type Todo = DB["todos"];
|
||||||
|
|
||||||
|
export const useTodoActions = () => {
|
||||||
|
const fetchTodos = () =>
|
||||||
|
$fetch<{ limit: number; todos: Array<Todo>; total: number }>("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "get" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTodo = (title: string) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "create", data: { title } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTodo = (todo: Todo) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "delete", data: { id: todo.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleTodo = (todo: Todo) =>
|
||||||
|
$fetch("/todos", {
|
||||||
|
method: "POST",
|
||||||
|
body: { action: "toggle", data: todo },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { fetchTodos, createTodo, deleteTodo, toggleTodo };
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { User } from "bknd";
|
||||||
|
|
||||||
|
export const useUser = () => {
|
||||||
|
const getUser = () => $fetch("/api/auth/me") as Promise<{ user: User }>;
|
||||||
|
return { getUser };
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const { fetchTodos, toggleTodo, createTodo, deleteTodo } = useTodoActions();
|
||||||
|
const { data: todos, refresh } = await useAsyncData('todos', () => fetchTodos());
|
||||||
|
|
||||||
|
async function handleSubmit(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget as HTMLFormElement;
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const title = formData.get("title");
|
||||||
|
await createTodo(title as string);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="todos !== undefined"
|
||||||
|
class="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||||
|
<main class="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||||
|
<div class="flex flex-row items-center justify-evenly min-w-full">
|
||||||
|
<img class="size-24" src="/nuxt.svg" alt="Nuxt logo" />
|
||||||
|
<div class="ml-3.5 mr-2 font-mono opacity-70">&</div>
|
||||||
|
<img class="dark:invert" src="/bknd.svg" alt="bknd logo" width="183" height="59" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<List :items="['Get started with a full backend.', 'Focus on what matters instead of repetition.']" />
|
||||||
|
|
||||||
|
<div class="flex flex-col border border-black/15 dark:border-white/15 w-full py-4 px-5 gap-2">
|
||||||
|
<h2 class="font-mono mb-1 opacity-70"><code>What's next?</code></h2>
|
||||||
|
<div class="flex flex-col w-full gap-2">
|
||||||
|
<div v-if="todos.total > todos.limit"
|
||||||
|
class="bg-foreground/10 flex justify-center p-1 text-xs rounded text-foreground/40">
|
||||||
|
{{ todos.total - todos.limit }} more todo(s) hidden
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div v-for="todo in todos.todos" :key="String(todo.id)" class="flex flex-row">
|
||||||
|
<div class="flex flex-row flex-grow items-center gap-3 ml-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="flex-shrink-0 cursor-pointer"
|
||||||
|
:checked="Boolean(todo.done)"
|
||||||
|
@change="() => { toggleTodo(todo); refresh() }" />
|
||||||
|
<div class="text-foreground/90 leading-none">{{ todo.title }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs"
|
||||||
|
@click="async () => { await deleteTodo(todo); refresh() }">
|
||||||
|
❌
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="flex flex-row w-full gap-3 mt-2" :key="todos.todos.map(t => t.id).join()" @submit="handleSubmit">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
placeholder="New todo"
|
||||||
|
class="py-2 px-4 flex flex-grow rounded-sm bg-black/5 focus:bg-black/10 dark:bg-white/5 dark:focus:bg-white/10 transition-colors outline-none" />
|
||||||
|
<button type="submit" class="cursor-pointer">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
const { getUser } = useUser();
|
||||||
|
const { data, status: userStatus, execute } = await useAsyncData("user", () => getUser());
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="userStatus !== 'pending'"
|
||||||
|
className="flex flex-col items-center justify-center min-h-screen p-8 pb-20 gap-16 sm:p-20"
|
||||||
|
>
|
||||||
|
<main className="flex flex-col gap-8 row-start-2 justify-center items-center sm:items-start">
|
||||||
|
<div class="flex flex-row items-center justify-evenly min-w-full">
|
||||||
|
<img class="size-24" src="/nuxt.svg" alt="Nuxt logo" />
|
||||||
|
<div class="ml-3.5 mr-2 font-mono opacity-70">&</div>
|
||||||
|
<img
|
||||||
|
class="dark:invert"
|
||||||
|
src="/bknd.svg"
|
||||||
|
alt="bknd logo"
|
||||||
|
width="183"
|
||||||
|
height="59"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="data?.user">
|
||||||
|
Logged in as {{ data.user.email }}.
|
||||||
|
<NuxtLink external className="font-medium underline" href='/api/auth/logout'>
|
||||||
|
Logout
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
<div v-else className="flex flex-col gap-1">
|
||||||
|
<p>
|
||||||
|
Not logged in.
|
||||||
|
<NuxtLink external className="font-medium underline" href="/admin/auth/login">
|
||||||
|
Login
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs opacity-50">
|
||||||
|
Sign in with:
|
||||||
|
<b>
|
||||||
|
<code>test@bknd.io</code>
|
||||||
|
</b>
|
||||||
|
/
|
||||||
|
<b>
|
||||||
|
<code>12345678</code>
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { em, entity, text, boolean, } from "bknd";
|
||||||
|
import { secureRandomString } from "bknd/utils";
|
||||||
|
import type { NuxtBkndConfig } from "bknd/adapter/nuxt";
|
||||||
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
|
|
||||||
|
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: secureRandomString(32),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
enabled: true,
|
||||||
|
adapter: local({
|
||||||
|
path: "./public/uploads",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
adminOptions: {
|
||||||
|
adminBasepath: "/admin",
|
||||||
|
assetsPath: "/admin/",
|
||||||
|
logoReturnPath: "../..",
|
||||||
|
},
|
||||||
|
} satisfies NuxtBkndConfig;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: false },
|
||||||
|
modules: ["@nuxtjs/tailwindcss"],
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: "Nuxt 🤝 Bknd.io",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
css: ["assets/css/main.css"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "nuxbknd",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"typegen": "bunx tsx node_modules/.bin/bknd types --outfile bknd-types.d.ts",
|
||||||
|
"postinstall": "nuxt prepare && bun run bknd copy-assets --out public/admin"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "6.14.0",
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"bknd": "file:../../app",
|
||||||
|
"nuxt": "^4.3.1",
|
||||||
|
"vue": "^3.5.28",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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 |
@@ -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,3 @@
|
|||||||
|
<svg viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M504.908 750H839.476C850.103 750.001 860.542 747.229 869.745 741.963C878.948 736.696 886.589 729.121 891.9 719.999C897.211 710.876 900.005 700.529 900 689.997C899.995 679.465 897.193 669.12 891.873 660.002L667.187 274.289C661.876 265.169 654.237 257.595 645.036 252.329C635.835 247.064 625.398 244.291 614.773 244.291C604.149 244.291 593.711 247.064 584.511 252.329C575.31 257.595 567.67 265.169 562.36 274.289L504.908 372.979L392.581 179.993C387.266 170.874 379.623 163.301 370.42 158.036C361.216 152.772 350.777 150 340.151 150C329.525 150 319.086 152.772 309.883 158.036C300.679 163.301 293.036 170.874 287.721 179.993L8.12649 660.002C2.80743 669.12 0.00462935 679.465 5.72978e-06 689.997C-0.00461789 700.529 2.78909 710.876 8.10015 719.999C13.4112 729.121 21.0523 736.696 30.255 741.963C39.4576 747.229 49.8973 750.001 60.524 750H270.538C353.748 750 415.112 713.775 457.336 643.101L559.849 467.145L614.757 372.979L779.547 655.834H559.849L504.908 750ZM267.114 655.737L120.551 655.704L340.249 278.586L449.87 467.145L376.474 593.175C348.433 639.03 316.577 655.737 267.114 655.737Z" fill="#00DC82"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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 |
@@ -0,0 +1,15 @@
|
|||||||
|
import { serve } from "bknd/adapter/nuxt";
|
||||||
|
import config from "../../bknd.config";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const pathname = event.path
|
||||||
|
const request = toWebRequest(event);
|
||||||
|
|
||||||
|
if (pathname.startsWith("/api") || pathname !== "/") {
|
||||||
|
const res = await serve(config, process.env)(request);
|
||||||
|
|
||||||
|
if (res && res.status !== 404) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event);
|
||||||
|
const { data, action } = body;
|
||||||
|
|
||||||
|
const api = await getApi({});
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'get': {
|
||||||
|
const limit = 5;
|
||||||
|
const todos = await api.data.readMany("todos", { limit, sort: "-id" });
|
||||||
|
return { total: todos.body.meta.total, todos, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create': {
|
||||||
|
return await api.data.createOne("todos", { title: data.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
return await api.data.deleteOne("todos", data.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'toggle': {
|
||||||
|
return await api.data.updateOne("todos", data.id, { done: !data.done });
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return { path: action };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { type NuxtBkndConfig, getApp as getNuxtApp } from "bknd/adapter/nuxt";
|
||||||
|
import bkndConfig from "../../bknd.config";
|
||||||
|
|
||||||
|
export async function getApp<Env = NodeJS.ProcessEnv>(
|
||||||
|
config: NuxtBkndConfig<Env>,
|
||||||
|
args: Env = process.env as Env,
|
||||||
|
) {
|
||||||
|
return await getNuxtApp(config, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getApi({ headers, verify }: { verify?: boolean; headers?: Headers }) {
|
||||||
|
const app = await getApp(bkndConfig, process.env);
|
||||||
|
|
||||||
|
if (verify) {
|
||||||
|
const api = app.getApi({ headers });
|
||||||
|
await api.verifyAuth();
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.getApi();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"files.readonlyInclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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 |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -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 |
@@ -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:
|
||||||
|
After Width: | Height: | Size: 259 KiB |
|
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 |
@@ -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>
|
||||||
|
);
|
||||||
|
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>>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">&</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.",
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
@@ -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">&</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||