diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ed2e73c --- /dev/null +++ b/CONTRIBUTING.md @@ -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. + + + + + +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 diff --git a/README.md b/README.md index 2d08d83..a28f54b 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,19 @@ npx bknd run ```bash npm install bknd ``` + +## Contributing + +We welcome contributions! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) before getting started. It covers dev setup, project structure, testing, code style, and how to submit changes. + +Before writing code, please open a GitHub Issue or start a conversation in [Discord](https://discord.com/invite/952SFk8Tb8) so we can align on the approach. + +## Contributors + +Thank you to everyone who has contributed to bknd. + + + + + +Made with [contrib.rocks](https://contrib.rocks). diff --git a/app/__test__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts index 8527b5d..8e8eb99 100644 --- a/app/__test__/adapter/adapter.test.ts +++ b/app/__test__/adapter/adapter.test.ts @@ -1,66 +1,76 @@ import { expect, describe, it, beforeAll, afterAll, mock } from "bun:test"; 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 { bunTestRunner } from "adapter/bun/test"; -import { omitKeys } from "core/utils"; + +const stripConnection = >(cfg: T) => + omitKeys(cfg, ["connection"]); beforeAll(disableConsoleLog); afterAll(enableConsoleLog); describe("adapter", () => { - it("makes config", async () => { - expect(omitKeys(await adapter.makeConfig({}), ["connection"])).toEqual({}); - expect( - omitKeys(await adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"]), - ).toEqual({}); + describe("makeConfig", () => { + it("returns empty config for empty inputs", async () => { + const cases: Array> = [ + [{}], + [{}, { env: { TEST: "test" } }], + ]; - // merges everything returned from `app` with the config - expect( - omitKeys( - await adapter.makeConfig( - { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, - { env: { TEST: "test" } }, - ), - ["connection"], - ), - ).toEqual({ - config: { server: { cors: { origin: "test" } } }, - }); - }); + for (const args of cases) { + const cfg = await adapter.makeConfig(...(args as any)); + expect(stripConnection(cfg)).toEqual({}); + } + }); - it("allows all properties in app function", async () => { - const called = mock(() => null); - const config = await adapter.makeConfig( - { - app: (env) => ({ - connection: { url: "test" }, - config: { server: { cors: { origin: "test" } } }, - options: { - mode: "db", - }, - onBuilt: () => { - called(); - expect(env).toEqual({ foo: "bar" }); - }, - }), - }, - { foo: "bar" }, + it("merges app output into config", async () => { + const cfg = await adapter.makeConfig( + { app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) }, + { env: { TEST: "test" } }, ); - expect(config.connection).toEqual({ url: "test" }); - expect(config.config).toEqual({ server: { cors: { origin: "test" } } }); - expect(config.options).toEqual({ mode: "db" }); - await config.onBuilt?.(null as any); - expect(called).toHaveBeenCalled(); - }); - adapterTestSuite(bunTestRunner, { + expect(stripConnection(cfg)).toEqual({ + config: { server: { cors: { origin: "test" } } }, + }); + }); + + it("allows all properties in app() result", async () => { + const called = mock(() => null); + + const cfg = await adapter.makeConfig( + { + app: (env) => ({ + connection: { url: "test" }, + config: { server: { cors: { origin: "test" } } }, + options: { mode: "db" as const }, + onBuilt: () => { + called(); + expect(env).toEqual({ foo: "bar" }); + }, + }), + }, + { foo: "bar" }, + ); + + expect(cfg.connection).toEqual({ url: "test" }); + expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } }); + expect(cfg.options).toEqual({ mode: "db" }); + + await cfg.onBuilt?.({} as any); + expect(called).toHaveBeenCalledTimes(1); + }); + }); + + describe("adapter test suites", () => { + adapterTestSuite(bunTestRunner, { makeApp: adapter.createFrameworkApp, label: "framework app", - }); + }); - adapterTestSuite(bunTestRunner, { + adapterTestSuite(bunTestRunner, { makeApp: adapter.createRuntimeApp, label: "runtime app", - }); + }); + }); }); diff --git a/app/build.ts b/app/build.ts index 3fd35ef..1ab90ea 100644 --- a/app/build.ts +++ b/app/build.ts @@ -2,7 +2,7 @@ import { $ } from "bun"; import * as tsup from "tsup"; import pkg from "./package.json" with { type: "json" }; 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"; const args = process.argv.slice(2); @@ -14,164 +14,178 @@ const clean = args.includes("--clean"); // silence tsup const oldConsole = { - log: console.log, - warn: console.warn, + log: console.log, + warn: console.warn, }; console.log = () => {}; console.warn = () => {}; const define = { - __isDev: "0", - __version: JSON.stringify(pkg.version), + __isDev: "0", + __version: JSON.stringify(pkg.version), }; if (clean) { 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; function buildTypes() { - if (types_running || !types) return; - types_running = true; + if (types_running || !types) return; + types_running = true; - Bun.spawn(["bun", "build:types"], { - stdout: "inherit", - onExit: () => { - oldConsole.log(c.cyan("[Types]"), c.green("built")); - Bun.spawn(["bun", "tsc-alias"], { - stdout: "inherit", - onExit: () => { - oldConsole.log(c.cyan("[Types]"), c.green("aliased")); - types_running = false; - }, - }); - }, - }); + Bun.spawn(["bun", "build:types"], { + stdout: "inherit", + onExit: () => { + oldConsole.log(c.cyan("[Types]"), c.green("built")); + Bun.spawn(["bun", "tsc-alias"], { + stdout: "inherit", + onExit: () => { + oldConsole.log(c.cyan("[Types]"), c.green("aliased")); + types_running = false; + }, + }); + }, + }); } if (types && !watch) { - buildTypes(); + buildTypes(); } let watcher_timeout: any; function delayTypes() { - if (!watch || !types) return; - if (watcher_timeout) { - clearTimeout(watcher_timeout); - } - watcher_timeout = setTimeout(buildTypes, 1000); + if (!watch || !types) return; + if (watcher_timeout) { + clearTimeout(watcher_timeout); + } + watcher_timeout = setTimeout(buildTypes, 1000); } const dependencies = Object.keys(pkg.dependencies); // collection of always-external packages const external = [ - ...dependencies, - "bun:test", - "node:test", - "node:assert/strict", - "@libsql/client", - "bknd", - /^bknd\/.*/, - "jsonv-ts", - /^jsonv-ts\/.*/, + ...dependencies, + "bun:test", + "node:test", + "node:assert/strict", + "@libsql/client", + "bknd", + /^bknd\/.*/, + "jsonv-ts", + /^jsonv-ts\/.*/, ] as const; /** * Building backend and general API */ async function buildApi() { - await tsup.build({ - minify, - sourcemap, - // don't use tsup's broken watch, we'll handle it ourselves - watch: false, - define, - entry: [ - "src/index.ts", - "src/core/utils/index.ts", - "src/plugins/index.ts", - "src/modes/index.ts", - ], - outDir: "dist", - external: [...external], - metafile: true, - target: "esnext", - platform: "browser", - removeNodeProtocol: false, - format: ["esm"], - splitting: false, - loader: { - ".svg": "dataurl", - }, - onSuccess: async () => { - delayTypes(); - oldConsole.log(c.cyan("[API]"), c.green("built")); - }, - }); + await tsup.build({ + minify, + sourcemap, + // don't use tsup's broken watch, we'll handle it ourselves + watch: false, + define, + entry: [ + "src/index.ts", + "src/core/utils/index.ts", + "src/plugins/index.ts", + "src/modes/index.ts", + ], + outDir: "dist", + external: [...external], + metafile: true, + target: "esnext", + platform: "browser", + removeNodeProtocol: false, + format: ["esm"], + splitting: false, + loader: { + ".svg": "dataurl", + }, + onSuccess: async () => { + delayTypes(); + oldConsole.log(c.cyan("[API]"), c.green("built")); + }, + }); } async function rewriteClient(path: string) { - const bundle = await Bun.file(path).text(); - await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client")); + const bundle = await Bun.file(path).text(); + await Bun.write( + path, + '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"), + ); } /** * Building UI for direct imports */ async function buildUi() { - const base = { - minify, - sourcemap, - watch: false, - define, - external: [ - ...external, - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "use-sync-external-store", - /codemirror/, - "@xyflow/react", - "@mantine/core", - ], - metafile: true, - platform: "browser", - format: ["esm"], - splitting: false, - bundle: true, - treeshake: true, - loader: { - ".svg": "dataurl", - }, - esbuildOptions: (options) => { - options.logLevel = "silent"; - }, - } satisfies tsup.Options; + const base = { + minify, + sourcemap, + watch: false, + define, + external: [ + ...external, + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "use-sync-external-store", + /codemirror/, + "@xyflow/react", + "@mantine/core", + ], + metafile: true, + platform: "browser", + format: ["esm"], + splitting: false, + bundle: true, + treeshake: true, + loader: { + ".svg": "dataurl", + }, + esbuildOptions: (options) => { + options.logLevel = "silent"; + }, + } satisfies tsup.Options; - await tsup.build({ - ...base, - entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], - outDir: "dist/ui", - onSuccess: async () => { - await rewriteClient("./dist/ui/index.js"); - delayTypes(); - oldConsole.log(c.cyan("[UI]"), c.green("built")); - }, - }); + await tsup.build({ + ...base, + entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"], + outDir: "dist/ui", + onSuccess: async () => { + await rewriteClient("./dist/ui/index.js"); + delayTypes(); + oldConsole.log(c.cyan("[UI]"), c.green("built")); + }, + }); - await tsup.build({ - ...base, - entry: ["src/ui/client/index.ts"], - outDir: "dist/ui/client", - onSuccess: async () => { - await rewriteClient("./dist/ui/client/index.js"); - delayTypes(); - oldConsole.log(c.cyan("[UI]"), "Client", c.green("built")); - }, - }); + await tsup.build({ + ...base, + entry: ["src/ui/client/index.ts"], + outDir: "dist/ui/client", + onSuccess: async () => { + await rewriteClient("./dist/ui/client/index.js"); + delayTypes(); + oldConsole.log(c.cyan("[UI]"), "Client", c.green("built")); + }, + }); } /** @@ -180,171 +194,185 @@ async function buildUi() { * - ui/client is external, and after built replaced with "bknd/client" */ async function buildUiElements() { - await tsup.build({ - minify, - sourcemap, - watch: false, - define, - entry: ["src/ui/elements/index.ts"], - outDir: "dist/ui/elements", - external: [ - "ui/client", - "bknd", - /^bknd\/.*/, - "wouter", - "react", - "react-dom", - "react/jsx-runtime", - "react/jsx-dev-runtime", - "use-sync-external-store", - ], - metafile: true, - platform: "browser", - format: ["esm"], - splitting: false, - bundle: true, - treeshake: true, - loader: { - ".svg": "dataurl", - }, - esbuildOptions: (options) => { - options.alias = { - // not important for elements, mock to reduce bundle - "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts", - }; - }, - onSuccess: async () => { - await rewriteClient("./dist/ui/elements/index.js"); - delayTypes(); - oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built")); - }, - }); + await tsup.build({ + minify, + sourcemap, + watch: false, + define, + entry: ["src/ui/elements/index.ts"], + outDir: "dist/ui/elements", + external: [ + "ui/client", + "bknd", + /^bknd\/.*/, + "wouter", + "react", + "react-dom", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "use-sync-external-store", + ], + metafile: true, + platform: "browser", + format: ["esm"], + splitting: false, + bundle: true, + treeshake: true, + loader: { + ".svg": "dataurl", + }, + esbuildOptions: (options) => { + options.alias = { + // not important for elements, mock to reduce bundle + "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts", + }; + }, + onSuccess: async () => { + await rewriteClient("./dist/ui/elements/index.js"); + delayTypes(); + oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built")); + }, + }); } /** * Building adapters */ -function baseConfig(adapter: string, overrides: Partial = {}): tsup.Options { - return { - minify, - sourcemap, - watch: false, - entry: [`src/adapter/${adapter}/index.ts`], - format: ["esm"], - platform: "neutral", - outDir: `dist/adapter/${adapter}`, - metafile: true, - splitting: false, - removeNodeProtocol: false, - onSuccess: async () => { - delayTypes(); - oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); - }, - ...overrides, - define: { - ...define, - ...overrides.define, - }, - external: [ - /^cloudflare*/, - /^@?hono.*?/, - /^(bknd|react|next|node).*?/, - /.*\.(html)$/, - ...external, - ...(Array.isArray(overrides.external) ? overrides.external : []), - ], - }; +function baseConfig( + adapter: string, + overrides: Partial = {}, +): tsup.Options { + return { + minify, + sourcemap, + watch: false, + entry: [`src/adapter/${adapter}/index.ts`], + format: ["esm"], + platform: "neutral", + outDir: `dist/adapter/${adapter}`, + metafile: true, + splitting: false, + removeNodeProtocol: false, + onSuccess: async () => { + delayTypes(); + oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built")); + }, + ...overrides, + define: { + ...define, + ...overrides.define, + }, + external: [ + /^cloudflare*/, + /^@?hono.*?/, + /^(bknd|react|next|node).*?/, + /.*\.(html)$/, + ...external, + ...(Array.isArray(overrides.external) ? overrides.external : []), + ], + }; } async function buildAdapters() { - await Promise.all([ - // base adapter handles - tsup.build({ - ...baseConfig(""), - target: "esnext", - platform: "neutral", - entry: ["src/adapter/index.ts"], - outDir: "dist/adapter", - // only way to keep @vite-ignore comments - minify: false, - }), + await Promise.all([ + // base adapter handles + tsup.build({ + ...baseConfig(""), + target: "esnext", + platform: "neutral", + entry: ["src/adapter/index.ts"], + outDir: "dist/adapter", + // only way to keep @vite-ignore comments + minify: false, + }), - // specific adatpers - tsup.build(baseConfig("react-router")), - tsup.build( - baseConfig("browser", { - external: [/^sqlocal\/?.*?/, "wouter"], - }), - ), - tsup.build( - baseConfig("bun", { - external: [/^bun\:.*/], - }), - ), - tsup.build(baseConfig("astro")), - tsup.build(baseConfig("aws")), - tsup.build( - baseConfig("cloudflare", { - external: ["wrangler", "node:process"], - }), - ), - tsup.build( - baseConfig("cloudflare/proxy", { - target: "esnext", - entry: ["src/adapter/cloudflare/proxy.ts"], - outDir: "dist/adapter/cloudflare", - metafile: false, - external: [/bknd/, "wrangler", "node:process"], - }), - ), - - tsup.build({ - ...baseConfig("vite"), - platform: "node", + // specific adatpers + tsup.build(baseConfig("react-router")), + tsup.build( + baseConfig("browser", { + external: [/^sqlocal\/?.*?/, "wouter"], }), - - tsup.build({ - ...baseConfig("nextjs"), - platform: "node", + ), + tsup.build( + baseConfig("bun", { + external: [/^bun\:.*/], }), - - tsup.build({ - ...baseConfig("sveltekit"), - platform: "node", + ), + tsup.build(baseConfig("astro")), + tsup.build(baseConfig("aws")), + tsup.build( + baseConfig("cloudflare", { + external: ["wrangler", "node:process"], }), - - tsup.build({ - ...baseConfig("node"), - platform: "node", + ), + tsup.build( + baseConfig("cloudflare/proxy", { + target: "esnext", + entry: ["src/adapter/cloudflare/proxy.ts"], + outDir: "dist/adapter/cloudflare", + metafile: false, + external: [/bknd/, "wrangler", "node:process"], }), + ), - tsup.build({ - ...baseConfig("sqlite/edge"), - entry: ["src/adapter/sqlite/edge.ts"], - outDir: "dist/adapter/sqlite", - metafile: false, - }), + tsup.build({ + ...baseConfig("vite"), + platform: "node", + }), - tsup.build({ - ...baseConfig("sqlite/node"), - entry: ["src/adapter/sqlite/node.ts"], - outDir: "dist/adapter/sqlite", - platform: "node", - metafile: false, - }), + tsup.build({ + ...baseConfig("nextjs"), + platform: "node", + }), - tsup.build({ - ...baseConfig("sqlite/bun"), - entry: ["src/adapter/sqlite/bun.ts"], - outDir: "dist/adapter/sqlite", - metafile: false, - external: [/^bun\:.*/], - }), - ]); + tsup.build({ + ...baseConfig("tanstack-start"), + platform: "node", + }), + + tsup.build({ + ...baseConfig("sveltekit"), + platform: "node", + }), + + tsup.build({ + ...baseConfig("node"), + platform: "node", + }), + + tsup.build({ + ...baseConfig("sqlite/edge"), + entry: ["src/adapter/sqlite/edge.ts"], + outDir: "dist/adapter/sqlite", + metafile: false, + }), + + tsup.build({ + ...baseConfig("sqlite/node"), + entry: ["src/adapter/sqlite/node.ts"], + outDir: "dist/adapter/sqlite", + platform: "node", + metafile: false, + }), + + tsup.build({ + ...baseConfig("sqlite/bun"), + entry: ["src/adapter/sqlite/bun.ts"], + outDir: "dist/adapter/sqlite", + metafile: false, + external: [/^bun\:.*/], + }), + + ]); } async function buildAll() { - await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]); + await Promise.all([ + buildApi(), + buildUi(), + buildUiElements(), + buildAdapters(), + ]); } // initial build @@ -352,39 +380,47 @@ await buildAll(); // custom watcher since tsup's watch is broken in 8.3.5+ if (watch) { - oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/..."); + oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/..."); - let debounceTimer: ReturnType | null = null; - let isBuilding = false; + let debounceTimer: ReturnType | null = null; + let isBuilding = false; - const rebuild = async () => { - if (isBuilding) return; - isBuilding = true; - oldConsole.log(c.cyan("[Watch]"), "rebuilding..."); - try { - await buildAll(); - oldConsole.log(c.cyan("[Watch]"), c.green("done")); - } catch (e) { - oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e); - } - isBuilding = false; - }; + const rebuild = async () => { + if (isBuilding) return; + isBuilding = true; + oldConsole.log(c.cyan("[Watch]"), "rebuilding..."); + try { + await buildAll(); + oldConsole.log(c.cyan("[Watch]"), c.green("done")); + } catch (e) { + oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e); + } + isBuilding = false; + }; - const debouncedRebuild = () => { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(rebuild, 100); - }; + const debouncedRebuild = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(rebuild, 100); + }; - // watch src directory recursively - fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => { + // watch src directory recursively + fsWatch( + join(import.meta.dir, "src"), + { recursive: true }, + (event, filename) => { if (!filename) return; // ignore non-source files - if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css")) - return; + if ( + !filename.endsWith(".ts") && + !filename.endsWith(".tsx") && + !filename.endsWith(".css") + ) + return; oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`)); debouncedRebuild(); - }); + }, + ); - // keep process alive - await new Promise(() => {}); + // keep process alive + await new Promise(() => {}); } diff --git a/app/e2e/inc/adapters.ts b/app/e2e/inc/adapters.ts index 347d23b..30b647b 100644 --- a/app/e2e/inc/adapters.ts +++ b/app/e2e/inc/adapters.ts @@ -1,44 +1,47 @@ const adapter = process.env.TEST_ADAPTER; const default_config = { - media_adapter: "local", - base_path: "", + media_adapter: "local", + base_path: "", } as const; const configs = { - cloudflare: { - media_adapter: "r2", - }, - "react-router": { - base_path: "/admin", - }, - nextjs: { - base_path: "/admin", - }, - astro: { - base_path: "/admin", - }, - node: { - base_path: "", - }, - bun: { - base_path: "", - }, + cloudflare: { + media_adapter: "r2", + }, + "react-router": { + base_path: "/admin", + }, + nextjs: { + base_path: "/admin", + }, + astro: { + base_path: "/admin", + }, + node: { + base_path: "", + }, + bun: { + base_path: "", + }, + "tanstack-start": { + base_path: "/admin", + }, }; export function getAdapterConfig(): typeof default_config { - if (adapter) { - if (!configs[adapter]) { - console.warn( - `Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`, - ); - } else { - return { - ...default_config, - ...configs[adapter], - }; - } - } + if (adapter) { + if (!configs[adapter]) { + console.warn( + `Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`, + ); + } else { + return { + ...default_config, + ...configs[adapter], + }; + } + } - return default_config; + return default_config; } diff --git a/app/package.json b/app/package.json index 3027760..ceb3771 100644 --- a/app/package.json +++ b/app/package.json @@ -268,6 +268,11 @@ "import": "./dist/adapter/browser/index.js", "require": "./dist/adapter/browser/index.js" }, + "./adapter/tanstack-start": { + "types": "./dist/types/adapter/tanstack-start/index.d.ts", + "import": "./dist/adapter/tanstack-start/index.js", + "require": "./dist/adapter/tanstack-start/index.js" + }, "./dist/main.css": "./dist/ui/main.css", "./dist/styles.css": "./dist/ui/styles.css", "./dist/manifest.json": "./dist/static/.vite/manifest.json", @@ -286,6 +291,7 @@ "adapter/bun": ["./dist/types/adapter/bun/index.d.ts"], "adapter/node": ["./dist/types/adapter/node/index.d.ts"], "adapter/sveltekit": ["./dist/types/adapter/sveltekit/index.d.ts"], + "adapter/tanstack-start": ["./dist/types/adapter/tanstack-start/index.d.ts"], "adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"] } }, diff --git a/app/src/adapter/tanstack-start/index.ts b/app/src/adapter/tanstack-start/index.ts new file mode 100644 index 0000000..b6a4846 --- /dev/null +++ b/app/src/adapter/tanstack-start/index.ts @@ -0,0 +1 @@ +export * from "./tanstack-start.adapter"; diff --git a/app/src/adapter/tanstack-start/tanstack-start.adapter.spec.ts b/app/src/adapter/tanstack-start/tanstack-start.adapter.spec.ts new file mode 100644 index 0000000..1101f61 --- /dev/null +++ b/app/src/adapter/tanstack-start/tanstack-start.adapter.spec.ts @@ -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(bunTestRunner, { + makeApp: tanstackStart.getApp, + makeHandler: tanstackStart.serve, + }); +}); diff --git a/app/src/adapter/tanstack-start/tanstack-start.adapter.ts b/app/src/adapter/tanstack-start/tanstack-start.adapter.ts new file mode 100644 index 0000000..55151fe --- /dev/null +++ b/app/src/adapter/tanstack-start/tanstack-start.adapter.ts @@ -0,0 +1,33 @@ +import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter"; + +export type TanstackStartEnv = NodeJS.ProcessEnv; + +export type TanstackStartConfig = + FrameworkBkndConfig; + +/** + * Get bknd app instance + * @param config - bknd configuration + * @param args - environment variables + */ +export async function getApp( + config: TanstackStartConfig = {}, + 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( + config: TanstackStartConfig = {}, + args: Env = process.env as Env, +) { + return async (request: Request) => { + const app = await getApp(config, args); + return app.fetch(request); + }; +} diff --git a/docs/content/docs/(documentation)/extending/admin.mdx b/docs/content/docs/(documentation)/extending/admin.mdx index a2adede..8b35029 100644 --- a/docs/content/docs/(documentation)/extending/admin.mdx +++ b/docs/content/docs/(documentation)/extending/admin.mdx @@ -7,7 +7,7 @@ import { TypeTable } from "fumadocs-ui/components/type-table"; bknd features an integrated Admin UI that can be used to: -- fully manage your backend visually when run in [`db` mode](/usage/introduction/#ui-only-mode) +- fully manage your backend visually when run in [`db` mode](/usage/setup/#ui-only-mode) - manage your database contents - manage your media contents diff --git a/docs/content/docs/(documentation)/extending/config.mdx b/docs/content/docs/(documentation)/extending/config.mdx index e6b0a4e..369f66a 100644 --- a/docs/content/docs/(documentation)/extending/config.mdx +++ b/docs/content/docs/(documentation)/extending/config.mdx @@ -121,7 +121,7 @@ If the connection object is omitted, the app will try to use an in-memory databa As configuration, you can either pass a partial configuration object or a complete one with a version number. The version number is used to automatically migrate the configuration up -to the latest version upon boot ([`db` mode](/usage/introduction#ui-only-mode) only). The default configuration looks like this: +to the latest version upon boot ([`db` mode](/usage/setup#ui-only-mode) only). The default configuration looks like this: ```json { diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx index be96c3a..8313f7d 100644 --- a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx +++ b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx @@ -170,7 +170,7 @@ export const prerender = false; theme: "dark", logo_return_path: "/../" }} - client:only + client:only="react" /> diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/meta.json b/docs/content/docs/(documentation)/integration/(frameworks)/meta.json index 6fb7bfc..5cc7900 100644 --- a/docs/content/docs/(documentation)/integration/(frameworks)/meta.json +++ b/docs/content/docs/(documentation)/integration/(frameworks)/meta.json @@ -1,3 +1,10 @@ { - "pages": ["nextjs", "react-router", "astro", "sveltekit", "vite"] + "pages": [ + "nextjs", + "react-router", + "astro", + "sveltekit", + "tanstack-start", + "vite" + ] } diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/tanstack-start.mdx b/docs/content/docs/(documentation)/integration/(frameworks)/tanstack-start.mdx new file mode 100644 index 0000000..f34b599 --- /dev/null +++ b/docs/content/docs/(documentation)/integration/(frameworks)/tanstack-start.mdx @@ -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: + + + +```bash tab="npm" +npm install bknd +``` + +```bash tab="pnpm" +pnpm install bknd +``` + +```bash tab="yarn" +yarn add bknd +``` + +```bash tab="bun" +bun add bknd +``` + + + +## Configuration + + + 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. + + +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 = FrameworkBkndConfig; +``` + +## 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(); +}; +``` + + + 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. + + +## 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 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 + + + +## 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 ( +
+

Todos

+
    + {todos.map((todo) => ( +
  • {todo.title}
  • + ))} +
+
+ ); +} +``` + + + +### 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 ( +
+ {user ? ( + <> + Logged in as {user.email}.{" "} + + Logout + + + ) : ( +
+

+ Not logged in. + + Login + +

+

+ Sign in with: + + test@bknd.io + + / + + 12345678 + +

+
+ )} +
+ ) +} +``` + +Check the [Tanstack Start repository example](https://github.com/bknd-io/bknd/tree/main/examples/tanstack-start) for more implementation details. diff --git a/docs/content/docs/(documentation)/integration/introduction.mdx b/docs/content/docs/(documentation)/integration/introduction.mdx index 7119efd..6b94374 100644 --- a/docs/content/docs/(documentation)/integration/introduction.mdx +++ b/docs/content/docs/(documentation)/integration/introduction.mdx @@ -33,6 +33,12 @@ bknd seamlessly integrates with popular frameworks, allowing you to use what you href="/integration/sveltekit" /> +} + title="Tanstack Start" + href="/integration/tanstack-start" +/> + Create a new issue to request a guide for your framework. diff --git a/docs/content/docs/(documentation)/meta.json b/docs/content/docs/(documentation)/meta.json index 442c37d..a2b624f 100644 --- a/docs/content/docs/(documentation)/meta.json +++ b/docs/content/docs/(documentation)/meta.json @@ -9,7 +9,7 @@ "start", "motivation", "---Usage---", - "./usage/introduction", + "./usage/setup", "./usage/database", "./usage/cli", "./usage/sdk", diff --git a/docs/content/docs/(documentation)/modules/auth.mdx b/docs/content/docs/(documentation)/modules/auth.mdx index 069c7bb..72e082a 100644 --- a/docs/content/docs/(documentation)/modules/auth.mdx +++ b/docs/content/docs/(documentation)/modules/auth.mdx @@ -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 diff --git a/docs/content/docs/(documentation)/start.mdx b/docs/content/docs/(documentation)/start.mdx index 2b8e855..744588d 100644 --- a/docs/content/docs/(documentation)/start.mdx +++ b/docs/content/docs/(documentation)/start.mdx @@ -6,26 +6,36 @@ tags: ["documentation"] import { Icon } from "@iconify/react"; import { examples } from "@/app/_components/StackBlitz"; -import { SquareMousePointer, Code, Blend } from 'lucide-react'; +import { SquareMousePointer, Code, Blend, Rocket } from 'lucide-react'; - +## bknd is a lightweight batteries-included backend that embeds into your frontend app + + We're making great progress towards v1, but don't recommend production use yet. Follow along for updates on [GitHub Releases](https://github.com/bknd-io/bknd/releases) and in our [Discord community](https://discord.gg/952SFk8Tb8) -Welcome! bknd is the instant backend for your frontend with full REST API's, admin dashboard, auth and user management, file upload management, [type-safe SDK](/usage/sdk), [React hooks](/usage/react), and plugins to extend the bknd _(like our resend plugin for sending emails)_. bknd can be hosted with your server-side rendered (SSR) web app or hosted as a standalone app. - -bknd is incredibly lightweight and built upon Web Standards, so that you can bring bknd anywhere JavaScript runs. +bknd includes full REST APIs, an admin dashboard, auth, media uploads, a [type-safe SDK](/usage/sdk), [React hooks](/usage/react), and plugins to extend it. Host it with your SSR app or as a standalone service. Built on Web Standards, it runs anywhere JavaScript runs. Bring your [favorite frontend](./#start-with-a-frameworkruntime) and [favorite SQL database](./#use-your-favorite-sql-database), and we'll bring the ~~backend~~ bknd. -## Quickstart: bknd server and admin dashboard demo + + } + > + Why another backend system? + + + +## Quickstart Don't worry about messing anything up in this stage since you're learning the ropes of bknd. If you want to start over, please delete the generated `data.db` database file and follow this tutorial again. -Enter the following command to spin up a bknd instance via the [bknd CLI](/usage/cli): +Spin up a bknd instance via the [bknd CLI](/usage/cli): @@ -39,11 +49,9 @@ bunx bknd run -This will create a local `data.db` SQLite database file in the folder you ran the command and start the bknd web server at http://localhost:1337. +This creates a local `data.db` SQLite database and starts the bknd web server at http://localhost:1337. -By default, access to the admin dashboard is open and not guarded. This is intentional because bknd uses an opt-in philosophy model to allow for quick prototyping and intentional configuration. To restrict access and prevent unauthorized use, let's enable authentication and guard the dashboard. - -We will create a user and an admin role. We will then apply the admin role to the user. Then, we can lock down the admin dashboard by enabling the guard, securing access to your bknd app. Let's begin. +By default, the admin dashboard is open and not guarded. This is intentional — bknd uses an opt-in philosophy to allow quick prototyping. Let's enable authentication and guard the dashboard to secure it. 1. Visit http://localhost:1337/auth/settings. Toggle "Authentication Enabled" to enable auth. Select "Update" to save. 2. Visit http://localhost:1337/data/entity/users. Create a user by selecting "New User" and entering an email and password. @@ -52,29 +60,27 @@ We will create a user and an admin role. We will then apply the admin role to th 5. It's time to guard your admin dashboard. Visit http://localhost:1337/settings/auth. Select "Edit". Scroll to "Guard" and enable it. Then, select "Save". _(This should log you out!)_ 6. Now, log in to your secured admin dashboard at http://localhost:1337/auth/login. -You did it! 🥳 You've successfully started the bknd server and admin dashboard, created an admin user, and protected your bknd app by enabling the guard. +You did it! You've started the bknd server, created an admin user, and protected your app by enabling the guard. -What you've just experienced is called UI-only mode, where the bknd data and configuration is managed via the admin dashboard UI. Some may prefer the UI-only mode, while others may prefer the code-only mode. +## Modes -### bknd modes - -bknd supports multiple modes to suit your needs. +What you just experienced is **UI-only mode** — bknd's data and configuration is managed entirely via the admin dashboard. But that's not the only way to use bknd: - }> + }> Configure your backend and manage your data visually with the built-in Admin UI. - }> + }> Configure your backend programmatically with a Drizzle-like API, manage your data with the Admin UI. - }> + }> Configure your backend visually while in development, use a read-only configuration in production. -### Demo bknd in the browser +Learn more about each mode and the underlying configuration in [Setup & Modes](/usage/setup). -Here is a preview of bknd in StackBlitz: +## Try bknd in the browser @@ -90,7 +96,7 @@ Here is a preview of bknd in StackBlitz: ## Start with a Framework/Runtime -Start by using the integration guide for these popular frameworks/runtimes. There will be more in the future, so stay tuned! +Pick your framework or runtime to get started. } title="NextJS" href="/integration/nextjs" /> @@ -144,6 +150,12 @@ Start by using the integration guide for these popular frameworks/runtimes. Ther href="/integration/sveltekit" /> +} + title="Tanstack Start" + href="/integration/tanstack-start" +/> + } title="AWS Lambda" diff --git a/docs/content/docs/(documentation)/usage/cli.mdx b/docs/content/docs/(documentation)/usage/cli.mdx index 72b46f5..e97d481 100644 --- a/docs/content/docs/(documentation)/usage/cli.mdx +++ b/docs/content/docs/(documentation)/usage/cli.mdx @@ -263,7 +263,7 @@ To automatically sync your secrets to a file, you may also use the [`syncSecrets ## Syncing the database (`sync`) -Sync your database can be useful when running in [`code`](/usage/introduction/#code-only-mode) mode. When you're ready to deploy, you can point to the production configuration and sync the database. Schema mutations are only applied when running with the `--force` option. +Sync your database can be useful when running in [`code`](/usage/setup/#code-only-mode) mode. When you're ready to deploy, you can point to the production configuration and sync the database. Schema mutations are only applied when running with the `--force` option. ```bash $ npx bknd sync --help diff --git a/docs/content/docs/(documentation)/usage/database.mdx b/docs/content/docs/(documentation)/usage/database.mdx index 0b0db2f..103e6e9 100644 --- a/docs/content/docs/(documentation)/usage/database.mdx +++ b/docs/content/docs/(documentation)/usage/database.mdx @@ -310,7 +310,7 @@ const app = createApp({ connection }); ## Data Structure -To provide a database structure, you can pass `config` to the creation of an app. In [`db` mode](/usage/introduction#ui-only-mode), the data structure is only respected if the database is empty. If you made updates, ensure to delete the database first, or perform updates through the Admin UI. +To provide a database structure, you can pass `config` to the creation of an app. In [`db` mode](/usage/setup#ui-only-mode), the data structure is only respected if the database is empty. If you made updates, ensure to delete the database first, or perform updates through the Admin UI. Here is a quick example: @@ -506,9 +506,9 @@ const app = createApp({ }); ``` -Note that in [`db` mode](/usage/introduction#ui-only-mode), the seed function will only be executed on app's first boot. If a configuration already exists in the database, it will not be executed. +Note that in [`db` mode](/usage/setup#ui-only-mode), the seed function will only be executed on app's first boot. If a configuration already exists in the database, it will not be executed. -In [`code` mode](/usage/introduction#code-only-mode), the seed function will not be automatically executed. You can manually execute it by running the following command: +In [`code` mode](/usage/setup#code-only-mode), the seed function will not be automatically executed. You can manually execute it by running the following command: ```bash npx bknd sync --seed --force diff --git a/docs/content/docs/(documentation)/usage/introduction.mdx b/docs/content/docs/(documentation)/usage/introduction.mdx deleted file mode 100644 index c290a12..0000000 --- a/docs/content/docs/(documentation)/usage/introduction.mdx +++ /dev/null @@ -1,264 +0,0 @@ ---- -title: "Introduction" -description: "Setting up bknd" -icon: Pin -tags: ["documentation"] ---- - -import { TypeTable } from "fumadocs-ui/components/type-table"; -import { SquareMousePointer, Code, Blend } from 'lucide-react'; - -There are several methods to get **bknd** up and running. You can choose between these options: - -1. [Run it using the CLI](/usage/cli): That's the easiest and fastest way to get started. -2. Use a runtime like [Node](/integration/node), [Bun](/integration/bun) or - [Cloudflare](/integration/cloudflare) (workerd). This will run the API and UI in the runtime's - native server and serves the UI assets statically from `node_modules`. -3. Run it inside your React framework of choice like [Next.js](/integration/nextjs), - [Astro](/integration/astro) or [Remix](/integration/remix). - -There is also a fourth option, which is running it inside a -[Docker container](/integration/docker). This is essentially a wrapper around the CLI. - -## Basic setup - -Regardless of the method you choose, at the end all adapters come down to the actual -instantiation of the `App`, which in raw looks like this: - -```typescript -import { createApp, type BkndConfig } from "bknd"; - -// create the app -const config = { - /* ... */ -} satisfies BkndConfig; -const app = createApp(config); - -// build the app -await app.build(); - -// export for Web API compliant envs -export default app; -``` - -In Web API compliant environments, all you have to do is to default exporting the app, as it implements the `Fetch` API. In case an explicit `fetch` export is needed, you can use the `app.fetch` property. - -```typescript -const app = /* ... */; -export default { - fetch: app.fetch, -} -``` - -Check the integration details for your specific runtime or framework in the [integration](/integration/introduction) section. - -## Modes - -Main project goal is to provide a backend that can be configured visually with the built-in Admin UI. However, you may instead want to configure your backend programmatically, and define your data structure with a Drizzle-like API: - - - }> - This is the default mode, it allows visual configuration and saves the configuration to the database. Expects you to deploy your backend separately from your frontend. - - }> - This mode allows you to configure your backend programmatically, and define your data structure with a Drizzle-like API. Visual configuration controls are disabled. - - }> - This mode allows you to configure your backend visually while in development, and uses the produced configuration in a code-only mode for maximum performance. - - - -In the following sections, we'll cover the different modes in more detail. The configuration properties involved are the following: - -```typescript title="bknd.config.ts" -import type { BkndConfig } from "bknd"; - -export default { - config: { /* ... */ } - options: { - mode: "db", // or "code" - manager: { - secrets: { /* ... */ }, - storeSecrets: true, - }, - } -} satisfies BkndConfig; -``` - - - -### UI-only mode - -This mode is the default mode. It allows you to configure your backend visually with the built-in Admin UI. It expects that you deploy your backend separately from your frontend, and make changes there. No configuration is needed, however, if you want to provide an initial configuration, you can do so by passing a `config` object. - -```typescript -import type { BkndConfig } from "bknd"; - -export default { - // this will only be applied if the database is empty - config: { /* ... */ }, -} satisfies BkndConfig; -``` - - - Note that when using the default UI-mode, the initial configuration using the `config` property will only be applied if the database is empty. - - - -### Code-only mode - -This mode allows you to configure your backend programmatically, and define your data structure with a Drizzle-like API. Visual configuration controls are disabled. - -```typescript title="bknd.config.ts" -import { type BkndConfig, em, entity, text, boolean } from "bknd"; -import { secureRandomString } from "bknd/utils"; - -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -export default { - // example configuration - config: { - data: schema.toJSON(), - auth: { - enabled: true, - jwt: { - secret: secureRandomString(64), - }, - } - }, - options: { - // this ensures that the provided configuration is always used - mode: "code", - }, -} satisfies BkndConfig; -``` - -Unlike the UI-only mode, the configuration passed to `config` is always applied. In case you make data structure changes, you may need to sync the schema to the database manually, e.g. using the [sync command](/usage/cli#syncing-the-database-sync). - -### Hybrid mode - -This mode allows you to configure your backend visually while in development, and uses the produced configuration in a code-only mode for maximum performance. It gives you the best of both worlds. - -While in development, we set the mode to `"db"` where the configuration is stored in the database. When it's time to deploy, we export the configuration, and set the mode to `"code"`. While in `"db"` mode, the `config` property interprets the value as an initial configuration to use when the database is empty. - -```typescript title="bknd.config.ts" -import type { BkndConfig } from "bknd"; - -// import your produced configuration -import appConfig from "./appconfig.json" with { type: "json" }; - -export default { - config: appConfig, - options: { - mode: process.env.NODE_ENV === "development" ? "db" : "code", - manager: { - secrets: process.env - } - }, -} satisfies BkndConfig; -``` - -To keep your config, secrets and types in sync, you can either use the CLI or the plugins. - - -| Type | Plugin | CLI Command | -|----------------|-----------------------------------------------------------------------|----------------------------| -| Configuration | [`syncConfig`](/extending/plugins/#syncconfig) | [`config`](/usage/cli/#getting-the-configuration-config) | -| Secrets | [`syncSecrets`](/extending/plugins/#syncsecrets) | [`secrets`](/usage/cli/#getting-the-secrets-secrets) | -| Types | [`syncTypes`](/extending/plugins/#synctypes) | [`types`](/usage/cli/#generating-types-types) | - - -## Mode helpers - -To make the setup using your preferred mode easier, there are mode helpers for [`code`](/usage/introduction#code-only-mode) and [`hybrid`](/usage/introduction#hybrid-mode) modes. - -* built-in syncing of config, types and secrets -* let bknd automatically sync the data schema in development -* automatically switch modes in hybrid (from db to code) in production -* automatically skip config validation in production to boost performance - -To use it, you have to wrap your configuration in a mode helper, e.g. for `code` mode using the Bun adapter: - -```typescript title="bknd.config.ts" -import { code, type CodeMode } from "bknd/modes"; -import { type BunBkndConfig, writer } from "bknd/adapter/bun"; - -export default code({ - // some normal bun bknd config - connection: { url: "file:data.db" }, - // ... - // a writer is required, to sync the types - writer, - // (optional) mode specific config - isProduction: Bun.env.NODE_ENV === "production", - typesFilePath: "bknd-types.d.ts", - // (optional) e.g. have the schema synced if !isProduction - syncSchema: { - force: true, - drop: true, - } -}); -``` - -Similarily, for `hybrid` mode: - -```typescript title="bknd.config.ts" -import { hybrid, type HybridMode } from "bknd/modes"; -import { type BunBkndConfig, writer, reader } from "bknd/adapter/bun"; - -export default hybrid({ - // some normal bun bknd config - connection: { url: "file:data.db" }, - // ... - // reader/writer are required, to sync the types and config - writer, - reader, - // supply secrets - secrets: await Bun.file(".env.local").json(), - // (optional) mode specific config - isProduction: Bun.env.NODE_ENV === "production", - typesFilePath: "bknd-types.d.ts", - configFilePath: "bknd-config.json", - // (optional) and have them automatically written if !isProduction - syncSecrets: { - outFile: ".env.local", - format: "env", - includeSecrets: true, - }, - // (optional) also have the schema synced if !isProduction - syncSchema: { - force: true, - drop: true, - }, -}); -``` \ No newline at end of file diff --git a/docs/content/docs/(documentation)/usage/setup.mdx b/docs/content/docs/(documentation)/usage/setup.mdx new file mode 100644 index 0000000..51458f3 --- /dev/null +++ b/docs/content/docs/(documentation)/usage/setup.mdx @@ -0,0 +1,180 @@ +--- +title: "Setup & Modes" +description: "Choose a mode and get bknd running with your app" +icon: Pin +tags: ["documentation"] +--- + +import { SquareMousePointer, Code, Blend } from 'lucide-react'; + +bknd supports three modes. Each mode determines how your backend is configured and where that configuration lives. + +## Choose your mode + + + }> + Configure visually via the Admin UI. Config is stored in the database. + + }> + Define your schema in code with a Drizzle-like API. Config lives in your codebase. + + }> + Use the Admin UI in development, export to code for production. + + + +**Not sure which to pick?** + +- **UI-only** is best for prototyping and small projects. No code needed — configure everything through the dashboard. +- **Code-only** is best for teams, CI/CD, and version-controlled schemas. You define your data structure programmatically. +- **Hybrid** gives you the best of both — visual configuration while developing, locked-down code config in production. + +You can always change modes later. Start with UI-only if you're exploring. + +--- + +## UI-only mode + +This is the default. Run bknd and configure everything through the Admin UI. No setup code required beyond a database connection. + +```typescript title="bknd.config.ts" +import type { BkndConfig } from "bknd"; + +export default { + connection: { url: "file:data.db" }, +} satisfies BkndConfig; +``` + +If you want to provide an initial data structure (entities, auth settings, etc.), pass it via `config`. It will only be applied when the database is empty. + +```typescript title="bknd.config.ts" +import type { BkndConfig } from "bknd"; + +export default { + connection: { url: "file:data.db" }, + config: { + auth: { enabled: true }, + }, +} satisfies BkndConfig; +``` + + + In UI-only mode, the `config` property is only applied on first boot. After that, all changes are made through the Admin UI. + + +**Next step:** Pick your [framework or runtime integration](/integration/introduction) to wire bknd into your app. + +--- + +## Code-only mode + +Define your data structure programmatically with a Drizzle-like API. The Admin UI becomes read-only for configuration — you still use it to manage data. + +```typescript title="bknd.config.ts" +import { type BkndConfig, 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(64) }, + }, + }, + options: { + mode: "code", + }, +} satisfies BkndConfig; +``` + +Unlike UI-only mode, the `config` is applied on every boot. If you change the schema, you may need to [sync the database](/usage/cli#syncing-the-database-sync). + +**Next step:** Pick your [framework or runtime integration](/integration/introduction) to wire bknd into your app. + +--- + +## Hybrid mode + +Use the Admin UI to configure your backend while developing. When you're ready to deploy, export the config and run in code mode for production. + +```typescript title="bknd.config.ts" +import type { BkndConfig } from "bknd"; +import appConfig from "./appconfig.json" with { type: "json" }; + +export default { + connection: { url: "file:data.db" }, + config: appConfig, + options: { + mode: process.env.NODE_ENV === "development" ? "db" : "code", + manager: { + secrets: process.env, + }, + }, +} satisfies BkndConfig; +``` + +To export your config, secrets, and types, use the CLI or plugins: + +| What | CLI Command | Plugin | +|------|-------------|--------| +| Configuration | [`bknd config`](/usage/cli/#getting-the-configuration-config) | [`syncConfig`](/extending/plugins/#syncconfig) | +| Secrets | [`bknd secrets`](/usage/cli/#getting-the-secrets-secrets) | [`syncSecrets`](/extending/plugins/#syncsecrets) | +| Types | [`bknd types`](/usage/cli/#generating-types-types) | [`syncTypes`](/extending/plugins/#synctypes) | + +**Next step:** Pick your [framework or runtime integration](/integration/introduction) to wire bknd into your app. + +--- + +## Mode helpers + +For code and hybrid modes, bknd provides helper functions that handle syncing, mode switching, and schema validation automatically. + +```typescript title="bknd.config.ts (code mode with Bun)" +import { code } from "bknd/modes"; +import { type BunBkndConfig, writer } from "bknd/adapter/bun"; + +export default code({ + connection: { url: "file:data.db" }, + writer, + isProduction: Bun.env.NODE_ENV === "production", + typesFilePath: "bknd-types.d.ts", +}); +``` + +```typescript title="bknd.config.ts (hybrid mode with Bun)" +import { hybrid } from "bknd/modes"; +import { type BunBkndConfig, writer, reader } from "bknd/adapter/bun"; + +export default hybrid({ + connection: { url: "file:data.db" }, + writer, + reader, + secrets: await Bun.file(".env.local").json(), + isProduction: Bun.env.NODE_ENV === "production", + typesFilePath: "bknd-types.d.ts", + configFilePath: "bknd-config.json", +}); +``` + +Mode helpers give you: +- Built-in syncing of config, types, and secrets +- Automatic schema syncing in development +- Automatic mode switching (db → code) in production for hybrid +- Skipped config validation in production for faster boot + +--- + +## Further reading + +- [Framework & runtime integrations](/integration/introduction) — wire bknd into Next.js, Astro, Bun, Cloudflare, etc. +- [Database configuration](/usage/database) — choose and configure your SQL database +- [Configuration reference](/extending/config) — full `BkndConfig` API, plugins, events, and lifecycle hooks diff --git a/docs/redirects.config.mjs b/docs/redirects.config.mjs index 8eb900a..00ec94e 100644 --- a/docs/redirects.config.mjs +++ b/docs/redirects.config.mjs @@ -9,4 +9,9 @@ export const redirectsConfig = [ destination: "/start", permanent: true, }, + { + source: "/usage/introduction", + destination: "/usage/setup", + permanent: true, + }, ]; diff --git a/examples/tanstack-start/.gitignore b/examples/tanstack-start/.gitignore new file mode 100644 index 0000000..2396459 --- /dev/null +++ b/examples/tanstack-start/.gitignore @@ -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 \ No newline at end of file diff --git a/examples/tanstack-start/.vscode/settings.json b/examples/tanstack-start/.vscode/settings.json new file mode 100644 index 0000000..00b5278 --- /dev/null +++ b/examples/tanstack-start/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "files.watcherExclude": { + "**/routeTree.gen.ts": true + }, + "search.exclude": { + "**/routeTree.gen.ts": true + }, + "files.readonlyInclude": { + "**/routeTree.gen.ts": true + } +} diff --git a/examples/tanstack-start/README.md b/examples/tanstack-start/README.md new file mode 100644 index 0000000..af94249 --- /dev/null +++ b/examples/tanstack-start/README.md @@ -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` diff --git a/examples/tanstack-start/bknd.config.ts b/examples/tanstack-start/bknd.config.ts new file mode 100644 index 0000000..febdcff --- /dev/null +++ b/examples/tanstack-start/bknd.config.ts @@ -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; diff --git a/examples/tanstack-start/package.json b/examples/tanstack-start/package.json new file mode 100644 index 0000000..d26625f --- /dev/null +++ b/examples/tanstack-start/package.json @@ -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" + } +} diff --git a/examples/tanstack-start/public/bknd.ico b/examples/tanstack-start/public/bknd.ico new file mode 100644 index 0000000..c1a946d Binary files /dev/null and b/examples/tanstack-start/public/bknd.ico differ diff --git a/examples/tanstack-start/public/bknd.svg b/examples/tanstack-start/public/bknd.svg new file mode 100644 index 0000000..182ef92 --- /dev/null +++ b/examples/tanstack-start/public/bknd.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/examples/tanstack-start/public/favicon.ico b/examples/tanstack-start/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/examples/tanstack-start/public/favicon.ico differ diff --git a/examples/tanstack-start/public/file.svg b/examples/tanstack-start/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/examples/tanstack-start/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/tanstack-start/public/globe.svg b/examples/tanstack-start/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/examples/tanstack-start/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/tanstack-start/public/manifest.json b/examples/tanstack-start/public/manifest.json new file mode 100644 index 0000000..078ef50 --- /dev/null +++ b/examples/tanstack-start/public/manifest.json @@ -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" +} diff --git a/examples/tanstack-start/public/robots.txt b/examples/tanstack-start/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/examples/tanstack-start/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/examples/tanstack-start/public/tanstack-circle-logo.png b/examples/tanstack-start/public/tanstack-circle-logo.png new file mode 100644 index 0000000..9db3e67 Binary files /dev/null and b/examples/tanstack-start/public/tanstack-circle-logo.png differ diff --git a/examples/tanstack-start/public/tanstack-word-logo-white.svg b/examples/tanstack-start/public/tanstack-word-logo-white.svg new file mode 100644 index 0000000..b6ec508 --- /dev/null +++ b/examples/tanstack-start/public/tanstack-word-logo-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/tanstack-start/public/window.svg b/examples/tanstack-start/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/examples/tanstack-start/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/tanstack-start/src/bknd.ts b/examples/tanstack-start/src/bknd.ts new file mode 100644 index 0000000..3e0b9fb --- /dev/null +++ b/examples/tanstack-start/src/bknd.ts @@ -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(); +} diff --git a/examples/tanstack-start/src/components/Footer.tsx b/examples/tanstack-start/src/components/Footer.tsx new file mode 100644 index 0000000..e0d4da9 --- /dev/null +++ b/examples/tanstack-start/src/components/Footer.tsx @@ -0,0 +1,52 @@ +import { useRouterState, Link } from "@tanstack/react-router"; + +export function Footer() { + const routerState = useRouterState(); + const pathname = routerState.location.pathname; + + return ( +
+ + File icon + {pathname === "/" ? "SSR" : "Home"} + + + Window icon + Admin + + + Globe icon + Go to bknd.io → + +
+ ); +} diff --git a/examples/tanstack-start/src/components/List.tsx b/examples/tanstack-start/src/components/List.tsx new file mode 100644 index 0000000..5470ac0 --- /dev/null +++ b/examples/tanstack-start/src/components/List.tsx @@ -0,0 +1,9 @@ +export const List = ({ items = [] }: { items: React.ReactNode[] }) => ( +
    + {items.map((item, i) => ( +
  1. + {item} +
  2. + ))} +
+); diff --git a/examples/tanstack-start/src/logo.svg b/examples/tanstack-start/src/logo.svg new file mode 100644 index 0000000..fe53fe8 --- /dev/null +++ b/examples/tanstack-start/src/logo.svg @@ -0,0 +1,12 @@ + + + logo + + \ No newline at end of file diff --git a/examples/tanstack-start/src/routeTree.gen.ts b/examples/tanstack-start/src/routeTree.gen.ts new file mode 100644 index 0000000..13011d3 --- /dev/null +++ b/examples/tanstack-start/src/routeTree.gen.ts @@ -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() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/examples/tanstack-start/src/router.tsx b/examples/tanstack-start/src/router.tsx new file mode 100644 index 0000000..5c70836 --- /dev/null +++ b/examples/tanstack-start/src/router.tsx @@ -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 +} diff --git a/examples/tanstack-start/src/routes/__root.tsx b/examples/tanstack-start/src/routes/__root.tsx new file mode 100644 index 0000000..9593904 --- /dev/null +++ b/examples/tanstack-start/src/routes/__root.tsx @@ -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 ( + + + + + + + {children} + + , + }, + ]} + /> + + + + ); +} diff --git a/examples/tanstack-start/src/routes/admin.$.tsx b/examples/tanstack-start/src/routes/admin.$.tsx new file mode 100644 index 0000000..6e2971f --- /dev/null +++ b/examples/tanstack-start/src/routes/admin.$.tsx @@ -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 ( + + ); +} diff --git a/examples/tanstack-start/src/routes/api.$.ts b/examples/tanstack-start/src/routes/api.$.ts new file mode 100644 index 0000000..f65526c --- /dev/null +++ b/examples/tanstack-start/src/routes/api.$.ts @@ -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), + }, + }, +}); diff --git a/examples/tanstack-start/src/routes/index.tsx b/examples/tanstack-start/src/routes/index.tsx new file mode 100644 index 0000000..e2123d8 --- /dev/null +++ b/examples/tanstack-start/src/routes/index.tsx @@ -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 ( +
+
+
+ TanStack logo +
&
+ bknd logo +
+ +
+

+ What's next? +

+
+ {total > limit && ( +
+ {total - limit} more todo(s) hidden +
+ )} +
+ {todos.map((todo) => ( +
+
+ { + await updateTodo({ data: todo }); + router.invalidate(); + }} + /> +
+ {todo.title} +
+
+ +
+ ))} +
+
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(); + }} + > + + +
+
+
+
+
+
+ ); +} + +const Description = () => ( + +); + diff --git a/examples/tanstack-start/src/routes/ssr.tsx b/examples/tanstack-start/src/routes/ssr.tsx new file mode 100644 index 0000000..2782654 --- /dev/null +++ b/examples/tanstack-start/src/routes/ssr.tsx @@ -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 ( +
+
+
+ TanStack logo +
&
+ bknd logo +
+ todo.title)} /> + + +
+ {user ? ( + <> + Logged in as {user.email}.{" "} + + Logout + + + ) : ( +
+

+ Not logged in.{" "} + + Login + +

+

+ Sign in with:{" "} + + test@bknd.io + {" "} + /{" "} + + 12345678 + +

+
+ )} +
+
+
+
+ ); +} + +function Buttons() { + return ( + + ); +} diff --git a/examples/tanstack-start/src/styles.css b/examples/tanstack-start/src/styles.css new file mode 100644 index 0000000..1d1ea1e --- /dev/null +++ b/examples/tanstack-start/src/styles.css @@ -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; +} diff --git a/examples/tanstack-start/tsconfig.json b/examples/tanstack-start/tsconfig.json new file mode 100644 index 0000000..477479f --- /dev/null +++ b/examples/tanstack-start/tsconfig.json @@ -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/*"] + } + } +} diff --git a/examples/tanstack-start/vite.config.ts b/examples/tanstack-start/vite.config.ts new file mode 100644 index 0000000..a0b568e --- /dev/null +++ b/examples/tanstack-start/vite.config.ts @@ -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;