diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b167a9..5c3e1c6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { //"source.organizeImports.biome": "explicit", - //"source.fixAll.biome": "explicit" + "source.fixAll.biome": "explicit" }, "typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.autoImportFileExcludePatterns": [ diff --git a/app/__test__/app/modes.test.ts b/app/__test__/app/modes.test.ts new file mode 100644 index 0000000..034cf92 --- /dev/null +++ b/app/__test__/app/modes.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import { code, hybrid } from "modes"; + +describe("modes", () => { + describe("code", () => { + test("verify base configuration", async () => { + const c = code({}) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["options"]); + expect(config.options.mode).toEqual("code"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + + test("keeps overrides", async () => { + const c = code({ + connection: { + url: ":memory:", + }, + }) as any; + const config = await c.app?.({} as any); + expect(config.connection.url).toEqual(":memory:"); + }); + }); + + describe("hybrid", () => { + test("fails if no reader is provided", () => { + // @ts-ignore + expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/); + }); + test("verify base configuration", async () => { + const c = hybrid({ reader: async () => ({}) }) as any; + const config = await c.app?.({} as any); + expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]); + expect(config.options.mode).toEqual("db"); + expect(config.options.plugins).toEqual([]); + expect(config.options.manager.skipValidation).toEqual(false); + expect(config.options.manager.onModulesBuilt).toBeDefined(); + }); + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 51c9be3..c5d640d 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -43,6 +43,7 @@ export function createHandler( export function serve( { + app, distPath, connection, config: _config, @@ -62,6 +63,7 @@ export function serve( port, fetch: createHandler( { + app, connection, config: _config, options, diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index e263756..98df2b0 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -3,7 +3,7 @@ import type { RuntimeBkndConfig } from "bknd/adapter"; import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; -import type { MaybePromise } from "bknd"; +import type { App, MaybePromise } from "bknd"; import { $console } from "bknd/utils"; import { createRuntimeApp } from "bknd/adapter"; import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config"; @@ -55,8 +55,12 @@ export async function createApp( // compatiblity export const getFresh = createApp; +let app: App | undefined; export function serve( config: CloudflareBkndConfig = {}, + serveOptions?: (args: Env) => { + warm?: boolean; + }, ) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { @@ -92,8 +96,11 @@ export function serve( } } - const context = { request, env, ctx } as CloudflareContext; - const app = await createApp(config, context); + const { warm } = serveOptions?.(env) ?? {}; + if (!app || warm !== true) { + const context = { request, env, ctx } as CloudflareContext; + app = await createApp(config, context); + } return app.fetch(request, env, ctx); }, diff --git a/app/src/adapter/cloudflare/proxy.ts b/app/src/adapter/cloudflare/proxy.ts index 9efd5c4..3476315 100644 --- a/app/src/adapter/cloudflare/proxy.ts +++ b/app/src/adapter/cloudflare/proxy.ts @@ -65,37 +65,31 @@ export function withPlatformProxy( } return { - ...config, - beforeBuild: async (app, registries) => { - if (!use_proxy) return; - const env = await getEnv(); - registerMedia(env, registries as any); - await config?.beforeBuild?.(app, registries); - }, - bindings: async (env) => { - return (await config?.bindings?.(await getEnv(env))) || {}; - }, // @ts-ignore app: async (_env) => { const env = await getEnv(_env); const binding = use_proxy ? getBinding(env, "D1Database") : undefined; + const appConfig = typeof config.app === "function" ? await config.app(env) : config; + const connection = + use_proxy && binding + ? d1Sqlite({ + binding: binding.value as any, + }) + : appConfig.connection; - if (config?.app === undefined && use_proxy && binding) { - return { - connection: d1Sqlite({ - binding: binding.value, - }), - }; - } else if (typeof config?.app === "function") { - const appConfig = await config?.app(env); - if (binding) { - appConfig.connection = d1Sqlite({ - binding: binding.value, - }) as any; - } - return appConfig; - } - return config?.app || {}; + return { + ...appConfig, + beforeBuild: async (app, registries) => { + if (!use_proxy) return; + const env = await getEnv(); + registerMedia(env, registries as any); + await config?.beforeBuild?.(app, registries); + }, + bindings: async (env) => { + return (await config?.bindings?.(await getEnv(env))) || {}; + }, + connection, + }; }, } satisfies CloudflareBkndConfig; } diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 79f4c97..6568d29 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -14,14 +14,15 @@ import type { AdminControllerOptions } from "modules/server/AdminController"; import type { Manifest } from "vite"; export type BkndConfig = Merge< - CreateAppConfig & { - app?: - | Merge & Additional> - | ((args: Args) => MaybePromise, "app"> & Additional>>); - onBuilt?: (app: App) => MaybePromise; - beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; - buildConfig?: Parameters[0]; - } & Additional + CreateAppConfig & + Omit & { + app?: + | Omit, "app"> + | ((args: Args) => MaybePromise, "app">>); + onBuilt?: (app: App) => MaybePromise; + beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise; + buildConfig?: Parameters[0]; + } >; export type FrameworkBkndConfig = BkndConfig; diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 83feba8..d85d197 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -24,7 +24,7 @@ export async function createApp( path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"), ); if (relativeDistPath) { - console.warn("relativeDistPath is deprecated, please use distPath instead"); + $console.warn("relativeDistPath is deprecated, please use distPath instead"); } registerLocalMediaAdapter(); diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index ed2e1aa..b20822a 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -67,7 +67,10 @@ export async function startServer( $console.info("Server listening on", url); if (options.open) { - await open(url); + const p = await open(url, { wait: false }); + p.on("error", () => { + $console.warn("Couldn't open url in browser"); + }); } } diff --git a/app/src/data/entities/query/WithBuilder.ts b/app/src/data/entities/query/WithBuilder.ts index 5e9fd6a..07b4ac6 100644 --- a/app/src/data/entities/query/WithBuilder.ts +++ b/app/src/data/entities/query/WithBuilder.ts @@ -4,6 +4,7 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation"; import type { RepoQuery } from "data/server/query"; import { InvalidSearchParamsException } from "data/errors"; import type { Entity, EntityManager, RepositoryQB } from "data/entities"; +import { $console } from "bknd/utils"; export class WithBuilder { static addClause( @@ -13,7 +14,7 @@ export class WithBuilder { withs: RepoQuery["with"], ) { if (!withs || !isObject(withs)) { - console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); + $console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`); return qb; } @@ -37,9 +38,7 @@ export class WithBuilder { let subQuery = relation.buildWith(entity, ref)(eb); if (query) { subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, { - ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter( - Boolean, - ) as any, + ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any, }); } @@ -57,7 +56,7 @@ export class WithBuilder { static validateWiths(em: EntityManager, entity: string, withs: RepoQuery["with"]) { let depth = 0; if (!withs || !isObject(withs)) { - withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); + withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`); return depth; } diff --git a/app/src/data/fields/JsonSchemaField.ts b/app/src/data/fields/JsonSchemaField.ts index fed47bf..b182ac1 100644 --- a/app/src/data/fields/JsonSchemaField.ts +++ b/app/src/data/fields/JsonSchemaField.ts @@ -26,7 +26,12 @@ export class JsonSchemaField< constructor(name: string, config: Partial) { super(name, config); - this.validator = new Validator({ ...this.getJsonSchema() }); + + // make sure to hand over clean json + const schema = this.getJsonSchema(); + this.validator = new Validator( + typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {}, + ); } protected getSchema() { diff --git a/app/src/modes/code.ts b/app/src/modes/code.ts index 30e4dc3..6c147a3 100644 --- a/app/src/modes/code.ts +++ b/app/src/modes/code.ts @@ -10,16 +10,19 @@ export type CodeMode = AdapterConfig extends B ? BkndModeConfig : never; -export function code(config: BkndCodeModeConfig): BkndConfig { +export function code< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(codeConfig: CodeMode): BkndConfig { return { - ...config, + ...codeConfig, app: async (args) => { const { config: appConfig, plugins, isProd, syncSchemaOptions, - } = await makeModeConfig(config, args); + } = await makeModeConfig(codeConfig, args); if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") { $console.warn("You should not set a different mode than `db` when using code mode"); diff --git a/app/src/modes/hybrid.ts b/app/src/modes/hybrid.ts index c7c1c37..40fca8c 100644 --- a/app/src/modes/hybrid.ts +++ b/app/src/modes/hybrid.ts @@ -1,6 +1,6 @@ import type { BkndConfig } from "bknd/adapter"; import { makeModeConfig, type BkndModeConfig } from "./shared"; -import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd"; +import { getDefaultConfig, type MaybePromise, type Merge } from "bknd"; import type { DbModuleManager } from "modules/db/DbModuleManager"; import { invariant, $console } from "bknd/utils"; @@ -9,7 +9,7 @@ export type BkndHybridModeOptions = { * Reader function to read the configuration from the file system. * This is required for hybrid mode to work. */ - reader?: (path: string) => MaybePromise; + reader?: (path: string) => MaybePromise; /** * Provided secrets to be merged into the configuration */ @@ -23,8 +23,12 @@ export type HybridMode = AdapterConfig extends ? BkndModeConfig> : never; -export function hybrid(hybridConfig: HybridBkndConfig): BkndConfig { +export function hybrid< + Config extends BkndConfig, + Args = Config extends BkndConfig ? A : unknown, +>(hybridConfig: HybridMode): BkndConfig { return { + ...hybridConfig, app: async (args) => { const { config: appConfig, @@ -40,16 +44,15 @@ export function hybrid(hybridConfig: HybridBkndConfig): BkndConfig = BkndConfig< Merge >; +function _isProd() { + try { + return process.env.NODE_ENV === "production"; + } catch (_e) { + return false; + } +} + export async function makeModeConfig< Args = any, Config extends BkndModeConfig = BkndModeConfig, @@ -69,25 +77,24 @@ export async function makeModeConfig< if (typeof config.isProduction !== "boolean") { $console.warn( - "You should set `isProduction` option when using managed modes to prevent accidental issues", + "You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to", + _isProd(), ); } - invariant( - typeof config.writer === "function", - "You must set the `writer` option when using managed modes", - ); + let needsWriter = false; const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config; - const isProd = config.isProduction; + const isProd = config.isProduction ?? _isProd(); const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]); + const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd; const syncSchemaOptions = typeof config.syncSchema === "object" ? config.syncSchema : { - force: config.syncSchema !== false, - drop: true, + force: syncFallback, + drop: syncFallback, }; if (!isProd) { @@ -95,6 +102,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-types")) { throw new Error("You have to unregister the `syncTypes` plugin"); } + needsWriter = true; plugins.push( syncTypes({ enabled: true, @@ -114,6 +122,7 @@ export async function makeModeConfig< if (plugins.some((p) => p.name === "bknd-sync-config")) { throw new Error("You have to unregister the `syncConfig` plugin"); } + needsWriter = true; plugins.push( syncConfig({ enabled: true, @@ -142,6 +151,7 @@ export async function makeModeConfig< .join("."); } + needsWriter = true; plugins.push( syncSecrets({ enabled: true, @@ -174,6 +184,10 @@ export async function makeModeConfig< } } + if (needsWriter && typeof config.writer !== "function") { + $console.warn("You must set a `writer` function, attempts to write will fail"); + } + return { config, isProd, diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 8406eaa..706a8fd 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -223,7 +223,7 @@ export class ModuleManager { } extractSecrets() { - const moduleConfigs = structuredClone(this.configs()); + const moduleConfigs = JSON.parse(JSON.stringify(this.configs())); const secrets = { ...this.options?.secrets }; const extractedKeys: string[] = []; diff --git a/app/src/modules/server/AppServer.ts b/app/src/modules/server/AppServer.ts index 9434309..8352982 100644 --- a/app/src/modules/server/AppServer.ts +++ b/app/src/modules/server/AppServer.ts @@ -105,7 +105,10 @@ export class AppServer extends Module { if (err instanceof Error) { if (isDebug()) { - return c.json({ error: err.message, stack: err.stack }, 500); + return c.json( + { error: err.message, stack: err.stack?.split("\n").map((line) => line.trim()) }, + 500, + ); } } diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index f53798c..1042344 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -8,7 +8,7 @@ import type { ModuleApi, } from "bknd"; import { objectTransform, encodeSearch } from "bknd/utils"; -import type { Insertable, Selectable, Updateable } from "kysely"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; import useSWR, { type SWRConfiguration, type SWRResponse, mutate } from "swr"; import { type Api, useApi } from "ui/client"; @@ -33,6 +33,7 @@ interface UseEntityReturn< Entity extends keyof DB | string, Id extends PrimaryFieldType | undefined, Data = Entity extends keyof DB ? DB[Entity] : EntityData, + ActualId = Data extends { id: infer I } ? (I extends Generated ? T : I) : never, Response = ResponseObject>>, > { create: (input: Insertable) => Promise; @@ -42,9 +43,11 @@ interface UseEntityReturn< ResponseObject[] : Selectable>> >; update: Id extends undefined - ? (input: Updateable, id: Id) => Promise + ? (input: Updateable, id: ActualId) => Promise : (input: Updateable) => Promise; - _delete: Id extends undefined ? (id: Id) => Promise : () => Promise; + _delete: Id extends undefined + ? (id: PrimaryFieldType) => Promise + : () => Promise; } export const useEntity = < diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 255fe26..105b014 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -70,8 +70,9 @@ switch (dbType) { if (example) { const name = slugify(example); configPath = `.configs/${slugify(example)}.wrangler.json`; - const exists = await readFile(configPath, "utf-8"); - if (!exists) { + try { + await readFile(configPath, "utf-8"); + } catch (_e) { wranglerConfig.name = name; wranglerConfig.d1_databases[0]!.database_name = name; wranglerConfig.d1_databases[0]!.database_id = crypto.randomUUID(); diff --git a/docker/debug/Dockerfile.minimal b/docker/debug/Dockerfile.minimal new file mode 100644 index 0000000..07868fe --- /dev/null +++ b/docker/debug/Dockerfile.minimal @@ -0,0 +1,14 @@ +FROM alpine:latest + +# Install Node.js and npm +RUN apk add --no-cache nodejs npm + +# Set working directory +WORKDIR /app + +# Create package.json with type: module +RUN echo '{"type":"module"}' > package.json + +# Keep container running (can be overridden) +CMD ["sh"] + diff --git a/docker/debug/run-minimal.sh b/docker/debug/run-minimal.sh new file mode 100755 index 0000000..0a5ca28 --- /dev/null +++ b/docker/debug/run-minimal.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Build the minimal Alpine image with Node.js +docker build -f Dockerfile.minimal -t bknd-minimal . + +# Run the container with the whole app/src directory mapped +docker run -it --rm \ + -v "$(pwd)/../app:/app/app" \ + -w /app \ + -p 1337:1337 \ + bknd-minimal + diff --git a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx index 064a422..be96c3a 100644 --- a/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx +++ b/docs/content/docs/(documentation)/integration/(frameworks)/astro.mdx @@ -40,12 +40,6 @@ bun add bknd - - The guide below assumes you're using Astro v4. We've experienced issues with - Astro DB using v5, see [this - issue](https://github.com/withastro/astro/issues/12474). - - For the Astro integration to work, you also need to [add the react integration](https://docs.astro.build/en/guides/integrations-guide/react/): ```bash @@ -159,7 +153,7 @@ Create a new catch-all route at `src/pages/admin/[...admin].astro`: import { Admin } from "bknd/ui"; import "bknd/dist/styles.css"; -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../../../bknd.ts"; // /src/bknd.ts const api = await getApi(Astro, { mode: "dynamic" }); const user = api.getUser(); diff --git a/docs/content/docs/(documentation)/usage/introduction.mdx b/docs/content/docs/(documentation)/usage/introduction.mdx index adf6810..c290a12 100644 --- a/docs/content/docs/(documentation)/usage/introduction.mdx +++ b/docs/content/docs/(documentation)/usage/introduction.mdx @@ -213,9 +213,9 @@ To use it, you have to wrap your configuration in a mode helper, e.g. for `code` import { code, type CodeMode } from "bknd/modes"; import { type BunBkndConfig, writer } from "bknd/adapter/bun"; -const config = { +export default code({ // some normal bun bknd config - connection: { url: "file:test.db" }, + connection: { url: "file:data.db" }, // ... // a writer is required, to sync the types writer, @@ -227,9 +227,7 @@ const config = { force: true, drop: true, } -} satisfies CodeMode; - -export default code(config); +}); ``` Similarily, for `hybrid` mode: @@ -238,9 +236,9 @@ Similarily, for `hybrid` mode: import { hybrid, type HybridMode } from "bknd/modes"; import { type BunBkndConfig, writer, reader } from "bknd/adapter/bun"; -const config = { +export default hybrid({ // some normal bun bknd config - connection: { url: "file:test.db" }, + connection: { url: "file:data.db" }, // ... // reader/writer are required, to sync the types and config writer, @@ -262,7 +260,5 @@ const config = { force: true, drop: true, }, -} satisfies HybridMode; - -export default hybrid(config); +}); ``` \ No newline at end of file diff --git a/examples/.gitignore b/examples/.gitignore index d305846..6fec887 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -2,4 +2,5 @@ */bun.lock */deno.lock */node_modules -*/*.db \ No newline at end of file +*/*.db +*/worker-configuration.d.ts \ No newline at end of file diff --git a/examples/cloudflare-vite-code/.env.local b/examples/cloudflare-vite-code/.env.local new file mode 100644 index 0000000..ab1e1c5 --- /dev/null +++ b/examples/cloudflare-vite-code/.env.local @@ -0,0 +1 @@ +JWT_SECRET=secret \ No newline at end of file diff --git a/examples/cloudflare-vite-code/README.md b/examples/cloudflare-vite-code/README.md new file mode 100644 index 0000000..c54626f --- /dev/null +++ b/examples/cloudflare-vite-code/README.md @@ -0,0 +1,348 @@ +# bknd starter: Cloudflare Vite Code-Only +A fullstack React + Vite application with bknd integration, showcasing **code-only mode** and Cloudflare Workers deployment. + +## Key Features + +This example demonstrates a minimal, code-first approach to building with bknd: + +### 💻 Code-Only Mode +Define your entire backend **programmatically** using a Drizzle-like API. Your data structure, authentication, and configuration live directly in code with zero build-time tooling required. Perfect for developers who prefer traditional code-first workflows. + +### 🎯 Minimal Boilerplate +Unlike the hybrid mode template, this example uses **no automatic type generation**, **no filesystem plugins**, and **no auto-synced configuration files**. This simulates a typical development environment where you manage types generation manually. If you prefer automatic type generation, you can easily add it using the [CLI](https://docs.bknd.io/usage/cli#generating-types-types) or [Vite plugin](https://docs.bknd.io/extending/plugins#synctypes). + +### ⚡ Split Configuration Pattern +- **`config.ts`**: Main configuration that defines your schema and can be safely imported in your worker +- **`bknd.config.ts`**: Wraps the configuration with `withPlatformProxy` for CLI usage with Cloudflare bindings (should NOT be imported in your worker) + +This pattern prevents bundling `wrangler` into your worker while still allowing CLI access to Cloudflare resources. + +## Project Structure + +Inside of your project, you'll see the following folders and files: + +```text +/ +├── src/ +│ ├── app/ # React frontend application +│ │ ├── App.tsx +│ │ ├── routes/ +│ │ │ ├── admin.tsx # bknd Admin UI route +│ │ │ └── home.tsx # Example frontend route +│ │ └── main.tsx +│ └── worker/ +│ └── index.ts # Cloudflare Worker entry +├── config.ts # bknd configuration with schema definition +├── bknd.config.ts # CLI configuration with platform proxy +├── seed.ts # Optional: seed data for development +├── vite.config.ts # Standard Vite config (no bknd plugins) +├── package.json +└── wrangler.json # Cloudflare Workers configuration +``` + +## Cloudflare Resources + +- **D1:** `wrangler.json` declares a `DB` binding. In production, replace `database_id` with your own (`wrangler d1 create `). +- **R2:** Optional `BUCKET` binding is pre-configured to show how to add additional services. +- **Environment awareness:** `ENVIRONMENT` variable determines whether to sync the database schema automatically (development only). +- **Static assets:** The Assets binding points to `dist/client`. Run `npm run build` before `wrangler deploy` to upload the client bundle alongside the worker. + +## Admin UI & Frontend + +- `/admin` mounts `` from `bknd/ui` with `withProvider={{ user }}` so it respects the authenticated user returned by `useAuth`. +- `/` showcases `useEntityQuery("todos")`, mutation helpers, and authentication state — demonstrating how manually declared types flow into the React code. + + +## Configuration Files + +### `config.ts` +The main configuration file that uses the `code()` mode helper: + +```typescript +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { code } from "bknd/modes"; +import { boolean, em, entity, text } from "bknd"; + +// define your schema using a Drizzle-like API +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema for type completion (optional) +// alternatively, you can use the CLI to auto-generate types +type Database = (typeof schema)["DB"]; +declare module "bknd" { + interface DB extends Database {} +} + +export default code({ + app: (env) => ({ + config: { + // convert schema to JSON format + data: schema.toJSON(), + auth: { + enabled: true, + jwt: { + // secrets are directly passed to the config + secret: env.JWT_SECRET, + issuer: "cloudflare-vite-code-example", + }, + }, + }, + // disable the built-in admin controller (we render our own app) + adminOptions: false, + // determines whether the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + }), +}); +``` + +Key differences from hybrid mode: +- **No auto-generated files**: No `bknd-config.json`, `bknd-types.d.ts`, or `.env.example` +- **Manual type declaration**: Types are declared inline using `declare module "bknd"` +- **Direct secret access**: Secrets come directly from `env` parameters +- **Simpler setup**: No filesystem plugins or readers/writers needed + +If you prefer automatic type generation, you can add it later using: +- **CLI**: `npm run bknd -- types` (requires adding `typesFilePath` to config) +- **Plugin**: Import `syncTypes` plugin and configure it in your app + +### `bknd.config.ts` +Wraps the configuration for CLI usage with Cloudflare bindings: + +```typescript +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); +``` + +**Important**: Don't import this file in your worker, as it would bundle `wrangler` into your production code. This file is only used by the bknd CLI. + +### `vite.config.ts` +Standard Vite configuration without bknd-specific plugins: + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss(), cloudflare()], +}); +``` + +## Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +|:-------------------|:----------------------------------------------------------| +| `npm install` | Installs dependencies, generates types, and seeds database| +| `npm run dev` | Starts local dev server with Vite at `localhost:5173` | +| `npm run build` | Builds the application for production | +| `npm run preview` | Builds and previews the production build locally | +| `npm run deploy` | Builds, syncs the schema and deploys to Cloudflare Workers| +| `npm run bknd` | Runs bknd CLI commands | +| `npm run bknd:seed`| Seeds the database with example data | +| `npm run cf:types` | Generates Cloudflare Worker types from `wrangler.json` | +| `npm run check` | Type checks and does a dry-run deployment | + +## Development Workflow + +1. **Install dependencies:** + ```sh + npm install + ``` + This will install dependencies, generate Cloudflare types, and seed the database. + +2. **Start development server:** + ```sh + npm run dev + ``` + +3. **Define your schema in code** (`config.ts`): + ```typescript + const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), + }); + ``` + +4. **Manually declare types** (optional, but recommended for IDE support): + ```typescript + type Database = (typeof schema)["DB"]; + declare module "bknd" { + interface DB extends Database {} + } + ``` + +5. **Use the Admin UI** at `http://localhost:5173/admin` to: + - View and manage your data + - Monitor authentication + - Access database tools + + Note: In code mode, you cannot edit the schema through the UI. All schema changes must be done in `config.ts`. + +6. **Sync schema changes** to your database: + ```sh + # Local development (happens automatically on startup) + npm run dev + + # Production database (safe operations only) + CLOUDFLARE_ENV=production npm run bknd -- sync --force + ``` + +## Before You Deploy + +### 1. Create a D1 Database + +Create a database in your Cloudflare account: + +```sh +npx wrangler d1 create my-database +``` + +Update `wrangler.json` with your database ID: +```json +{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-database", + "database_id": "your-database-id-here" + } + ] +} +``` + +### 2. Set Required Secrets + +Set your secrets in Cloudflare Workers: + +```sh +# JWT secret (required for authentication) +npx wrangler secret put JWT_SECRET +``` + +You can generate a secure secret using: +```sh +# Using openssl +openssl rand -base64 64 +``` + +## Deployment + +Deploy to Cloudflare Workers: + +```sh +npm run deploy +``` + +This will: +1. Set `ENVIRONMENT=production` to prevent automatic schema syncing +2. Build the Vite application +3. Sync the database schema (safe operations only) +4. Deploy to Cloudflare Workers using Wrangler + +In production, bknd will: +- Use the configuration defined in `config.ts` +- Skip config validation for better performance +- Expect secrets to be provided via environment variables + +## How Code Mode Works + +1. **Define Schema:** Create entities and fields using the Drizzle-like API in `config.ts` +2. **Convert to JSON:** Use `schema.toJSON()` to convert your schema to bknd's configuration format +3. **Manual Types:** Optionally declare types inline for IDE support and type safety +4. **Deploy:** Same configuration runs in both development and production + +### Code Mode vs Hybrid Mode + +| Feature | Code Mode | Hybrid Mode | +|---------|-----------|-------------| +| Schema Definition | Code-only (`em`, `entity`, `text`) | Visual UI in dev, code in prod | +| Configuration Files | None (all in code) | Auto-generated `bknd-config.json` | +| Type Generation | Manual or opt-in | Automatic | +| Setup Complexity | Minimal | Requires plugins & filesystem access | +| Use Case | Traditional code-first workflows | Rapid prototyping, visual development | + +## Type Generation (Optional) + +This example intentionally **does not use automatic type generation** to simulate a typical development environment where types are managed manually. This approach: +- Reduces build complexity +- Eliminates dependency on build-time tooling +- Works in any environment without special plugins + +However, if you prefer automatic type generation, you can easily add it: + +### Option 1: Using the Vite Plugin and `code` helper presets +Add `typesFilePath` to your config: + +```typescript +export default code({ + typesFilePath: "./bknd-types.d.ts", + // ... rest of config +}); +``` + +For Cloudflare Workers, you'll need the `devFsVitePlugin`: +```typescript +// vite.config.ts +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + // ... + devFsVitePlugin({ configFile: "config.ts" }) + ], +}); +``` + +Finally, add the generated types to your `tsconfig.json`: +```json +{ + "compilerOptions": { + "types": ["./bknd-types.d.ts"] + } +} +``` + +This provides filesystem access for auto-syncing types despite Cloudflare's `unenv` restrictions. + +### Option 2: Using the CLI + +You may also use the CLI to generate types: + +```sh +npx bknd types --outfile ./bknd-types.d.ts +``` + +## Database Seeding + +Unlike UI-only and hybrid modes where bknd can automatically detect an empty database (by attempting to fetch the configuration. A "table not found" error indicates a fresh database), **code mode requires manual seeding**. This is because in code mode, the configuration is always provided from code, so bknd can't determine if the database is empty without additional queries, which would impact performance. + +This example includes a [`seed.ts`](./seed.ts) file that you can run manually. For Cloudflare, it uses `bknd.config.ts` (with `withPlatformProxy`) to access Cloudflare resources like D1 during CLI execution: + +```sh +npm run bknd:seed +``` + +The seed script manually checks if the database is empty before inserting data. See the [seed.ts](./seed.ts) file for implementation details. + +## Want to Learn More? + +- [Cloudflare Integration Documentation](https://docs.bknd.io/integration/cloudflare) +- [Code Mode Guide](https://docs.bknd.io/usage/introduction#code-only-mode) +- [Mode Helpers Documentation](https://docs.bknd.io/usage/introduction#mode-helpers) +- [Data Structure & Schema API](https://docs.bknd.io/usage/database#data-structure) +- [Discord Community](https://discord.gg/952SFk8Tb8) + diff --git a/examples/cloudflare-vite-code/bknd.config.ts b/examples/cloudflare-vite-code/bknd.config.ts new file mode 100644 index 0000000..8129c0e --- /dev/null +++ b/examples/cloudflare-vite-code/bknd.config.ts @@ -0,0 +1,15 @@ +/** + * This file gets automatically picked up by the bknd CLI. Since we're using cloudflare, + * we want to use cloudflare bindings (such as the database). To do this, we need to wrap + * the configuration with the `withPlatformProxy` helper function. + * + * Don't import this file directly in your app, otherwise "wrangler" will be bundled with your worker. + * That's why we split the configuration into two files: `bknd.config.ts` and `config.ts`. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); diff --git a/examples/cloudflare-vite-code/config.ts b/examples/cloudflare-vite-code/config.ts new file mode 100644 index 0000000..a0f0c8c --- /dev/null +++ b/examples/cloudflare-vite-code/config.ts @@ -0,0 +1,43 @@ +/// + +import type { CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { code } from "bknd/modes"; +import { boolean, em, entity, text } from "bknd"; + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +// alternatively, you can use the CLI to generate types +// learn more at https://docs.bknd.io/usage/cli/#generating-types-types +type Database = (typeof schema)["DB"]; +declare module "bknd" { + interface DB extends Database {} +} + +export default code({ + app: (env) => ({ + config: { + data: schema.toJSON(), + auth: { + enabled: true, + jwt: { + // unlike hybrid mode, secrets are directly passed to the config + secret: env.JWT_SECRET, + issuer: "cloudflare-vite-code-example", + }, + }, + }, + // we need to disable the admin controller using the vite plugin, since we want to render our own app + adminOptions: false, + // this is important to determine whether the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + + // note: usually you would use `options.seed` to seed the database, but since we're using code mode, + // we don't know when the db is empty. So we need to create a separate seed function, see `seed.ts`. + }), +}); diff --git a/examples/cloudflare-vite-code/index.html b/examples/cloudflare-vite-code/index.html new file mode 100644 index 0000000..5b7fd70 --- /dev/null +++ b/examples/cloudflare-vite-code/index.html @@ -0,0 +1,14 @@ + + + + + + + bknd + Vite + Cloudflare + React + TS + + + +
+ + + diff --git a/examples/cloudflare-vite-code/package.json b/examples/cloudflare-vite-code/package.json new file mode 100644 index 0000000..1c0a53c --- /dev/null +++ b/examples/cloudflare-vite-code/package.json @@ -0,0 +1,36 @@ +{ + "name": "cloudflare-vite-fullstack-bknd-code", + "description": "A template for building a React application with Vite, Cloudflare Workers, and bknd", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "cf:types": "wrangler types", + "bknd": "node --experimental-strip-types node_modules/.bin/bknd", + "bknd:seed": "NODE_NO_WARNINGS=1 node --experimental-strip-types seed.ts", + "check": "tsc && vite build && wrangler deploy --dry-run", + "deploy": "CLOUDFLARE_ENV=production vite build && CLOUDFLARE_ENV=production npm run bknd -- sync --force && wrangler deploy", + "dev": "vite", + "preview": "npm run build && vite preview", + "postinstall": "npm run cf:types && npm run bknd:seed" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.17", + "bknd": "file:../../app", + "hono": "4.10.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.17", + "wouter": "^3.7.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "typescript": "5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } +} diff --git a/examples/cloudflare-vite-code/public/vite.svg b/examples/cloudflare-vite-code/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-code/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/seed.ts b/examples/cloudflare-vite-code/seed.ts new file mode 100644 index 0000000..e548e69 --- /dev/null +++ b/examples/cloudflare-vite-code/seed.ts @@ -0,0 +1,28 @@ +/// + +import { createFrameworkApp } from "bknd/adapter"; +import config from "./bknd.config.ts"; + +const app = await createFrameworkApp(config, {}); + +const { + data: { count: usersCount }, +} = await app.em.repo("users").count(); +const { + data: { count: todosCount }, +} = await app.em.repo("todos").count(); + +// only run if the database is empty +if (usersCount === 0 && todosCount === 0) { + await app.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + await app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); +} + +process.exit(0); diff --git a/examples/cloudflare-vite-code/src/app/App.tsx b/examples/cloudflare-vite-code/src/app/App.tsx new file mode 100644 index 0000000..9aa6a5f --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/App.tsx @@ -0,0 +1,39 @@ +import { Router, Switch, Route } from "wouter"; +import Home from "./routes/home.tsx"; +import { lazy, Suspense, useEffect, useState } from "react"; +const Admin = lazy(() => import("./routes/admin.tsx")); +import { useAuth } from "bknd/client"; + +export default function App() { + const auth = useAuth(); + const [verified, setVerified] = useState(false); + + useEffect(() => { + auth.verify().then(() => setVerified(true)); + }, []); + + if (!verified) return null; + + return ( + + + + + + + + + +
+ 404 +
+
+
+
+ ); +} diff --git a/examples/cloudflare-vite-code/src/app/assets/bknd.svg b/examples/cloudflare-vite-code/src/app/assets/bknd.svg new file mode 100644 index 0000000..182ef92 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/bknd.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/assets/cloudflare.svg b/examples/cloudflare-vite-code/src/app/assets/cloudflare.svg new file mode 100644 index 0000000..3bb7ac0 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/cloudflare.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/assets/react.svg b/examples/cloudflare-vite-code/src/app/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/assets/vite.svg b/examples/cloudflare-vite-code/src/app/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-code/src/app/index.css b/examples/cloudflare-vite-code/src/app/index.css new file mode 100644 index 0000000..4e0bdd8 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/index.css @@ -0,0 +1,28 @@ +@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 flex; + font-family: Arial, Helvetica, sans-serif; +} + +#root { + width: 100%; + min-height: 100dvh; +} diff --git a/examples/cloudflare-vite-code/src/app/main.tsx b/examples/cloudflare-vite-code/src/app/main.tsx new file mode 100644 index 0000000..8d55eb2 --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { ClientProvider } from "bknd/client"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/examples/cloudflare-vite-code/src/app/routes/admin.tsx b/examples/cloudflare-vite-code/src/app/routes/admin.tsx new file mode 100644 index 0000000..93a4d9b --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/routes/admin.tsx @@ -0,0 +1,8 @@ +import { Admin, type BkndAdminProps } from "bknd/ui"; +import "bknd/dist/styles.css"; +import { useAuth } from "bknd/client"; + +export default function AdminPage(props: BkndAdminProps) { + const auth = useAuth(); + return ; +} diff --git a/examples/cloudflare-vite-code/src/app/routes/home.tsx b/examples/cloudflare-vite-code/src/app/routes/home.tsx new file mode 100644 index 0000000..5d2197a --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/routes/home.tsx @@ -0,0 +1,94 @@ +import { useAuth, useEntityQuery } from "bknd/client"; +import bkndLogo from "../assets/bknd.svg"; +import cloudflareLogo from "../assets/cloudflare.svg"; +import viteLogo from "../assets/vite.svg"; + +export default function Home() { + const auth = useAuth(); + + const limit = 5; + const { data: todos, ...$q } = useEntityQuery("todos", undefined, { + limit, + sort: "-id", + }); + + return ( +
+ ); +} diff --git a/examples/cloudflare-vite-code/src/app/vite-env.d.ts b/examples/cloudflare-vite-code/src/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cloudflare-vite-code/src/app/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cloudflare-vite-code/src/worker/index.ts b/examples/cloudflare-vite-code/src/worker/index.ts new file mode 100644 index 0000000..7f22fcf --- /dev/null +++ b/examples/cloudflare-vite-code/src/worker/index.ts @@ -0,0 +1,7 @@ +import { serve } from "bknd/adapter/cloudflare"; +import config from "../../config.ts"; + +export default serve(config, () => ({ + // since bknd is running code-only, we can use a pre-initialized app instance if available + warm: true, +})); diff --git a/examples/cloudflare-vite-code/tsconfig.app.json b/examples/cloudflare-vite-code/tsconfig.app.json new file mode 100644 index 0000000..643d6aa --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/app", "./config.ts"] +} diff --git a/examples/cloudflare-vite-code/tsconfig.json b/examples/cloudflare-vite-code/tsconfig.json new file mode 100644 index 0000000..c7155af --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ], + "compilerOptions": { + "types": ["./worker-configuration.d.ts", "node"] + } +} diff --git a/examples/cloudflare-vite-code/tsconfig.node.json b/examples/cloudflare-vite-code/tsconfig.node.json new file mode 100644 index 0000000..50130b3 --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite-code/tsconfig.worker.json b/examples/cloudflare-vite-code/tsconfig.worker.json new file mode 100644 index 0000000..8a7758a --- /dev/null +++ b/examples/cloudflare-vite-code/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["vite/client", "./worker-configuration.d.ts"] + }, + "include": ["src/worker", "src/config.ts"] +} diff --git a/examples/cloudflare-vite-code/vite.config.ts b/examples/cloudflare-vite-code/vite.config.ts new file mode 100644 index 0000000..7704b9f --- /dev/null +++ b/examples/cloudflare-vite-code/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss(), cloudflare()], + build: { + minify: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + }, +}); diff --git a/examples/cloudflare-vite-code/wrangler.json b/examples/cloudflare-vite-code/wrangler.json new file mode 100644 index 0000000..d47fedd --- /dev/null +++ b/examples/cloudflare-vite-code/wrangler.json @@ -0,0 +1,47 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-fullstack-bknd", + "main": "./src/worker/index.ts", + "compatibility_date": "2025-10-08", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "assets": { + "binding": "ASSETS", + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api*", "!/assets/*"] + }, + "vars": { + "ENVIRONMENT": "development" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ], + "env": { + "production": { + "vars": { + "ENVIRONMENT": "production" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ] + } + } +} diff --git a/examples/cloudflare-vite-hybrid/.env.example b/examples/cloudflare-vite-hybrid/.env.example new file mode 100644 index 0000000..ab058b1 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/.env.example @@ -0,0 +1 @@ +auth.jwt.secret=secret \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/README.md b/examples/cloudflare-vite-hybrid/README.md new file mode 100644 index 0000000..5f19c07 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/README.md @@ -0,0 +1,249 @@ +# bknd starter: Cloudflare Vite Hybrid +A fullstack React + Vite application with bknd integration, showcasing **hybrid mode** and Cloudflare Workers deployment. + +## Key Features + +This example demonstrates several advanced bknd features: + +### 🔄 Hybrid Mode +Configure your backend **visually in development** using the Admin UI, then automatically switch to **code-only mode in production** for maximum performance. Changes made in the Admin UI are automatically synced to `bknd-config.json` and type definitions are generated in `bknd-types.d.ts`. + +### 📁 Filesystem Access with Vite Plugin +Cloudflare's Vite plugin uses `unenv` which disables Node.js APIs like `fs`. This example uses bknd's `devFsVitePlugin` and `devFsWrite` to provide filesystem access during development, enabling automatic syncing of types and configuration. + +### ⚡ Split Configuration Pattern +- **`config.ts`**: Shared configuration that can be safely imported in your worker +- **`bknd.config.ts`**: Wraps the configuration with `withPlatformProxy` for CLI usage with Cloudflare bindings (should NOT be imported in your worker) + +This pattern prevents bundling `wrangler` into your worker while still allowing CLI access to Cloudflare resources. + +## Project Structure + +Inside of your project, you'll see the following folders and files: + +```text +/ +├── src/ +│ ├── app/ # React frontend application +│ │ ├── App.tsx +│ │ ├── routes/ +│ │ │ ├── admin.tsx # bknd Admin UI route +│ │ │ └── home.tsx # Example frontend route +│ │ └── main.tsx +│ └── worker/ +│ └── index.ts # Cloudflare Worker entry +├── config.ts # Shared bknd configuration (hybrid mode) +├── bknd.config.ts # CLI configuration with platform proxy +├── bknd-config.json # Auto-generated production config +├── bknd-types.d.ts # Auto-generated TypeScript types +├── .env.example # Auto-generated secrets template +├── vite.config.ts # Includes devFsVitePlugin +├── package.json +└── wrangler.json # Cloudflare Workers configuration +``` + +## Cloudflare resources + +- **D1:** `wrangler.json` declares a `DB` binding. In production, replace `database_id` with your own (`wrangler d1 create `). +- **R2:** Optional `BUCKET` binding is pre-configured to show how to add additional services. +- **Environment awareness:** `ENVIRONMENT` switch toggles hybrid behavior: production makes the database read-only, while development keeps `mode: "db"` and auto-syncs schema. +- **Static assets:** The Assets binding points to `dist/client`. Run `npm run build` before `wrangler deploy` to upload the client bundle alongside the worker. + +## Admin UI & frontend + +- `/admin` mounts `` from `bknd/ui` with `withProvider={{ user }}` so it respects the authenticated user returned by `useAuth`. +- `/` showcases `useEntityQuery("todos")`, mutation helpers, and authentication state — demonstrating how the generated client types (`bknd-types.d.ts`) flow into the React code. + + +## Configuration Files + +### `config.ts` +The main configuration file that uses the `hybrid()` mode helper: + + - Loads the generated config via an ESM `reader` (importing `./bknd-config.json`). + - Uses `devFsWrite` as the `writer` so the CLI/plugin can persist files even though Node's `fs` API is unavailable in Miniflare. + - Sets `typesFilePath`, `configFilePath`, and `syncSecrets` (writes `.env.example`) so config, types, and secret placeholders stay aligned. + - Seeds example data/users in `options.seed` when the database is empty. + - Disables the built-in admin controller because the React app renders `/admin` via `bknd/ui`. + + +```typescript +import { hybrid } from "bknd/modes"; +import { devFsWrite, type CloudflareBkndConfig } from "bknd/adapter/cloudflare"; + +export default hybrid({ + // Special reader for Cloudflare Workers (no Node.js fs) + reader: async () => (await import("./bknd-config.json")).default, + // devFsWrite enables file writing via Vite plugin + writer: devFsWrite, + // Auto-sync these files in development + typesFilePath: "./bknd-types.d.ts", + configFilePath: "./bknd-config.json", + syncSecrets: { + enabled: true, + outFile: ".env.example", + format: "env", + }, + app: (env) => ({ + adminOptions: false, // Disabled - we render React app instead + isProduction: env.ENVIRONMENT === "production", + secrets: env, + // ... your configuration + }), +}); +``` + +### `bknd.config.ts` +Wraps the configuration for CLI usage with Cloudflare bindings: + +```typescript +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config); +``` + +### `vite.config.ts` +Includes the `devFsVitePlugin` for filesystem access: + +```typescript +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + // ... + devFsVitePlugin({ configFile: "config.ts" }), + cloudflare(), + ], +}); +``` + +## Commands + +All commands are run from the root of the project, from a terminal: + +| Command | Action | +|:-------------------|:----------------------------------------------------------| +| `npm install` | Installs dependencies and generates wrangler types | +| `npm run dev` | Starts local dev server with Vite at `localhost:5173` | +| `npm run build` | Builds the application for production | +| `npm run preview` | Builds and previews the production build locally | +| `npm run deploy` | Builds, syncs the schema and deploys to Cloudflare Workers| +| `npm run bknd` | Runs bknd CLI commands | +| `npm run bknd:types` | Generates TypeScript types from your schema | +| `npm run cf:types` | Generates Cloudflare Worker types from `wrangler.json` | +| `npm run check` | Type checks and does a dry-run deployment | + +## Development Workflow + +1. **Install dependencies:** + ```sh + npm install + ``` + +2. **Start development server:** + ```sh + npm run dev + ``` + +3. **Visit the Admin UI** at `http://localhost:5173/admin` to configure your backend visually: + - Create entities and fields + - Configure authentication + - Set up relationships + - Define permissions + +4. **Watch for auto-generated files:** + - `bknd-config.json` - Production configuration + - `bknd-types.d.ts` - TypeScript types + - `.env.example` - Required secrets + +5. **Use the CLI** for manual operations: + ```sh + # Generate types manually + npm run bknd:types + + # Sync the production database schema (only safe operations are applied) + CLOUDFLARE_ENV=production npm run bknd -- sync --force + ``` + +## Before you deploy + +If you're using a D1 database, make sure to create a database in your Cloudflare account and replace the `database_id` accordingly in `wrangler.json`: + +```sh +npx wrangler d1 create my-database +``` + +Update `wrangler.json`: +```json +{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-database", + "database_id": "your-database-id-here" + } + ] +} +``` + +## Deployment + +Deploy to Cloudflare Workers: + +```sh +npm run deploy +``` + +This will: +1. Set `ENVIRONMENT=production` to activate code-only mode +2. Build the Vite application +3. Deploy to Cloudflare Workers using Wrangler + +In production, bknd will: +- Use the configuration from `bknd-config.json` (read-only) +- Skip config validation for better performance +- Expect secrets to be provided via environment variables + +## Environment Variables + +Make sure to set your secrets in the Cloudflare Workers dashboard or via Wrangler: + +```sh +# Example: Set JWT secret +npx wrangler secret put auth.jwt.secret +``` + +Check `.env.example` for all required secrets after running the app in development mode. + +## How Hybrid Mode Works + +```mermaid +graph LR + A[Development] -->|Visual Config| B[Admin UI] + B -->|Auto-sync| C[bknd-config.json] + B -->|Auto-sync| D[bknd-types.d.ts] + C -->|Deploy| E[Production] + E -->|Read-only| F[Code-only Mode] +``` + +1. **In Development:** `mode: "db"` - Configuration stored in database, editable via Admin UI +2. **Auto-sync:** Changes automatically written to `bknd-config.json` and types to `bknd-types.d.ts` +3. **In Production:** `mode: "code"` - Configuration read from `bknd-config.json`, no database overhead + +## Why devFsVitePlugin? + +Cloudflare's Vite plugin removes Node.js APIs for Workers compatibility. This breaks filesystem operations needed for: +- Auto-syncing TypeScript types (`syncTypes` plugin) +- Auto-syncing configuration (`syncConfig` plugin) +- Auto-syncing secrets (`syncSecrets` plugin) + +The `devFsVitePlugin` + `devFsWrite` combination provides a workaround by using Vite's module system to enable file writes during development. + +## Want to learn more? + +- [Cloudflare Integration Documentation](https://docs.bknd.io/integration/cloudflare) +- [Hybrid Mode Guide](https://docs.bknd.io/usage/introduction#hybrid-mode) +- [Mode Helpers Documentation](https://docs.bknd.io/usage/introduction#mode-helpers) +- [Discord Community](https://discord.gg/952SFk8Tb8) + diff --git a/examples/cloudflare-vite-hybrid/bknd-config.json b/examples/cloudflare-vite-hybrid/bknd-config.json new file mode 100644 index 0000000..fb703aa --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd-config.json @@ -0,0 +1,204 @@ +{ + "server": { + "cors": { + "origin": "*", + "allow_methods": [ + "GET", + "POST", + "PATCH", + "PUT", + "DELETE" + ], + "allow_headers": [ + "Content-Type", + "Content-Length", + "Authorization", + "Accept" + ], + "allow_credentials": true + }, + "mcp": { + "enabled": false, + "path": "/api/system/mcp", + "logLevel": "emergency" + } + }, + "data": { + "basepath": "/api/data", + "default_primary_format": "integer", + "entities": { + "todos": { + "type": "regular", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "title": { + "type": "text", + "config": { + "required": false + } + }, + "done": { + "type": "boolean", + "config": { + "required": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + }, + "users": { + "type": "system", + "fields": { + "id": { + "type": "primary", + "config": { + "format": "integer", + "fillable": false, + "required": false + } + }, + "email": { + "type": "text", + "config": { + "required": true + } + }, + "strategy": { + "type": "enum", + "config": { + "options": { + "type": "strings", + "values": [ + "password" + ] + }, + "required": true, + "hidden": [ + "update", + "form" + ], + "fillable": [ + "create" + ] + } + }, + "strategy_value": { + "type": "text", + "config": { + "fillable": [ + "create" + ], + "hidden": [ + "read", + "table", + "update", + "form" + ], + "required": true + } + }, + "role": { + "type": "enum", + "config": { + "options": { + "type": "objects", + "values": [] + }, + "required": false + } + } + }, + "config": { + "sort_field": "id", + "sort_dir": "asc" + } + } + }, + "relations": {}, + "indices": { + "idx_unique_users_email": { + "entity": "users", + "fields": [ + "email" + ], + "unique": true + }, + "idx_users_strategy": { + "entity": "users", + "fields": [ + "strategy" + ], + "unique": false + }, + "idx_users_strategy_value": { + "entity": "users", + "fields": [ + "strategy_value" + ], + "unique": false + } + } + }, + "auth": { + "enabled": true, + "basepath": "/api/auth", + "entity_name": "users", + "allow_register": true, + "jwt": { + "secret": "", + "alg": "HS256", + "expires": 0, + "issuer": "bknd-cloudflare-example", + "fields": [ + "id", + "email", + "role" + ] + }, + "cookie": { + "domain": "", + "path": "/", + "sameSite": "strict", + "secure": true, + "httpOnly": true, + "expires": 604800, + "partitioned": false, + "renew": true, + "pathSuccess": "/", + "pathLoggedOut": "/" + }, + "strategies": { + "password": { + "enabled": true, + "type": "password", + "config": { + "hashing": "sha256" + } + } + }, + "guard": { + "enabled": false + }, + "roles": {} + }, + "media": { + "enabled": false, + "basepath": "/api/media", + "entity_name": "media", + "storage": {} + }, + "flows": { + "basepath": "/api/flows", + "flows": {} + } +} \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/bknd-types.d.ts b/examples/cloudflare-vite-hybrid/bknd-types.d.ts new file mode 100644 index 0000000..db7bae6 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd-types.d.ts @@ -0,0 +1,22 @@ +import type { DB } from "bknd"; +import type { Insertable, Selectable, Updateable, Generated } from "kysely"; + +declare global { + type BkndEntity = Selectable; + type BkndEntityCreate = Insertable; + type BkndEntityUpdate = Updateable; +} + +export interface Todos { + id: Generated; + title?: string; + done?: boolean; +} + +interface Database { + todos: Todos; +} + +declare module "bknd" { + interface DB extends Database {} +} \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/bknd.config.ts b/examples/cloudflare-vite-hybrid/bknd.config.ts new file mode 100644 index 0000000..8129c0e --- /dev/null +++ b/examples/cloudflare-vite-hybrid/bknd.config.ts @@ -0,0 +1,15 @@ +/** + * This file gets automatically picked up by the bknd CLI. Since we're using cloudflare, + * we want to use cloudflare bindings (such as the database). To do this, we need to wrap + * the configuration with the `withPlatformProxy` helper function. + * + * Don't import this file directly in your app, otherwise "wrangler" will be bundled with your worker. + * That's why we split the configuration into two files: `bknd.config.ts` and `config.ts`. + */ + +import { withPlatformProxy } from "bknd/adapter/cloudflare/proxy"; +import config from "./config.ts"; + +export default withPlatformProxy(config, { + useProxy: true, +}); diff --git a/examples/cloudflare-vite-hybrid/config.ts b/examples/cloudflare-vite-hybrid/config.ts new file mode 100644 index 0000000..2177649 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/config.ts @@ -0,0 +1,47 @@ +/// + +import { devFsWrite, type CloudflareBkndConfig } from "bknd/adapter/cloudflare"; +import { hybrid } from "bknd/modes"; + +export default hybrid({ + // normally you would use e.g. `readFile` from `node:fs/promises`, however, cloudflare using vite plugin removes all Node APIs, therefore we need to use the module system to import the config file + reader: async () => { + return (await import("./bknd-config.json").then((module) => module.default)) as any; + }, + // a writer is required to sync the types and config. We're using a vite plugin that proxies writing files (since Node APIs are not available) + writer: devFsWrite, + // the generated types are loaded using our tsconfig, and is automatically available in all bknd APIs + typesFilePath: "./bknd-types.d.ts", + // on every change, this config file is updated. When it's time to deploy, this will be inlined into your worker + configFilePath: "./bknd-config.json", + // secrets will always be extracted from the configuration, we're writing an example env file to know which secrets we need to provide prior to deploying + syncSecrets: { + enabled: true, + outFile: ".env.example", + format: "env", + } as const, + app: (env) => ({ + // we need to disable the admin controller using the vite plugin, since we want to render our own app + adminOptions: false, + // this is important to determine whether configuration should be read-only, or if the database should be automatically synced + isProduction: env.ENVIRONMENT === "production", + // we need to inject the secrets that gets merged into the configuration + secrets: env, + 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", + }); + }, + }, + }), +}); diff --git a/examples/cloudflare-vite-hybrid/index.html b/examples/cloudflare-vite-hybrid/index.html new file mode 100644 index 0000000..5b7fd70 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/index.html @@ -0,0 +1,14 @@ + + + + + + + bknd + Vite + Cloudflare + React + TS + + + +
+ + + diff --git a/examples/cloudflare-vite-hybrid/package.json b/examples/cloudflare-vite-hybrid/package.json new file mode 100644 index 0000000..71e3ae9 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/package.json @@ -0,0 +1,36 @@ +{ + "name": "cloudflare-vite-fullstack-bknd", + "description": "A template for building a React application with Vite, Cloudflare Workers, and bknd", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "cf:types": "wrangler types", + "bknd": "node --experimental-strip-types node_modules/.bin/bknd", + "bknd:types": "bknd -- types", + "check": "tsc && vite build && wrangler deploy --dry-run", + "deploy": "CLOUDFLARE_ENV=production vite build && CLOUDFLARE_ENV=production npm run bknd -- sync --force && wrangler deploy", + "dev": "vite", + "preview": "npm run build && vite preview", + "postinstall": "npm run cf:types" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.17", + "bknd": "file:../../app", + "hono": "4.10.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwindcss": "^4.1.17", + "wouter": "^3.7.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.15.2", + "@types/node": "^24.10.1", + "@types/react": "19.2.6", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.1.1", + "typescript": "5.9.3", + "vite": "^7.2.4", + "wrangler": "^4.50.0" + } +} diff --git a/examples/cloudflare-vite-hybrid/public/vite.svg b/examples/cloudflare-vite-hybrid/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-hybrid/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/App.tsx b/examples/cloudflare-vite-hybrid/src/app/App.tsx new file mode 100644 index 0000000..9aa6a5f --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/App.tsx @@ -0,0 +1,39 @@ +import { Router, Switch, Route } from "wouter"; +import Home from "./routes/home.tsx"; +import { lazy, Suspense, useEffect, useState } from "react"; +const Admin = lazy(() => import("./routes/admin.tsx")); +import { useAuth } from "bknd/client"; + +export default function App() { + const auth = useAuth(); + const [verified, setVerified] = useState(false); + + useEffect(() => { + auth.verify().then(() => setVerified(true)); + }, []); + + if (!verified) return null; + + return ( + + + + + + + + + +
+ 404 +
+
+
+
+ ); +} diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg b/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg new file mode 100644 index 0000000..182ef92 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/bknd.svg @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg b/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg new file mode 100644 index 0000000..3bb7ac0 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/cloudflare.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/react.svg b/examples/cloudflare-vite-hybrid/src/app/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg b/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/assets/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/cloudflare-vite-hybrid/src/app/index.css b/examples/cloudflare-vite-hybrid/src/app/index.css new file mode 100644 index 0000000..4e0bdd8 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/index.css @@ -0,0 +1,28 @@ +@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 flex; + font-family: Arial, Helvetica, sans-serif; +} + +#root { + width: 100%; + min-height: 100dvh; +} diff --git a/examples/cloudflare-vite-hybrid/src/app/main.tsx b/examples/cloudflare-vite-hybrid/src/app/main.tsx new file mode 100644 index 0000000..8d55eb2 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; +import { ClientProvider } from "bknd/client"; + +createRoot(document.getElementById("root")!).render( + + + + + +); diff --git a/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx b/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx new file mode 100644 index 0000000..93a4d9b --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/routes/admin.tsx @@ -0,0 +1,8 @@ +import { Admin, type BkndAdminProps } from "bknd/ui"; +import "bknd/dist/styles.css"; +import { useAuth } from "bknd/client"; + +export default function AdminPage(props: BkndAdminProps) { + const auth = useAuth(); + return ; +} diff --git a/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx b/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx new file mode 100644 index 0000000..91cb1f2 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/routes/home.tsx @@ -0,0 +1,99 @@ +import { useAuth, useEntityQuery } from "bknd/client"; +import bkndLogo from "../assets/bknd.svg"; +import cloudflareLogo from "../assets/cloudflare.svg"; +import viteLogo from "../assets/vite.svg"; + +export default function Home() { + const auth = useAuth(); + + const limit = 5; + const { data: todos, ...$q } = useEntityQuery("todos", undefined, { + limit, + sort: "-id", + }); + + return ( +
+
+ bknd +
&
+
+ cloudflare +
+
+ vite +
+
+ +
+

+ What's next? +

+
+
+ {todos && + [...todos].reverse().map((todo) => ( +
+
+ { + await $q.update( + { done: !todo.done }, + todo.id + ); + }} + /> +
+ {todo.title} +
+
+ +
+ ))} +
+
t.id).join()} + action={async (formData: FormData) => { + const title = formData.get("title") as string; + await $q.create({ title }); + }} + > + + +
+
+
+ +
+ Go to Admin. ➝ +
+ {auth.user ? ( +

+ Authenticated as {auth.user.email} +

+ ) : ( + Login + )} +
+
+
+ ); +} diff --git a/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts b/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/app/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/cloudflare-vite-hybrid/src/worker/index.ts b/examples/cloudflare-vite-hybrid/src/worker/index.ts new file mode 100644 index 0000000..7bf59ff --- /dev/null +++ b/examples/cloudflare-vite-hybrid/src/worker/index.ts @@ -0,0 +1,4 @@ +import { serve } from "bknd/adapter/cloudflare"; +import config from "../../config.ts"; + +export default serve(config); diff --git a/examples/cloudflare-vite-hybrid/tsconfig.app.json b/examples/cloudflare-vite-hybrid/tsconfig.app.json new file mode 100644 index 0000000..23edca7 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/app"] +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.json b/examples/cloudflare-vite-hybrid/tsconfig.json new file mode 100644 index 0000000..b3e17e0 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.worker.json" } + ], + "compilerOptions": { + "types": ["./worker-configuration.d.ts", "./bknd-types.d.ts", "node"] + } +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.node.json b/examples/cloudflare-vite-hybrid/tsconfig.node.json new file mode 100644 index 0000000..50130b3 --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/cloudflare-vite-hybrid/tsconfig.worker.json b/examples/cloudflare-vite-hybrid/tsconfig.worker.json new file mode 100644 index 0000000..8a7758a --- /dev/null +++ b/examples/cloudflare-vite-hybrid/tsconfig.worker.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", + "types": ["vite/client", "./worker-configuration.d.ts"] + }, + "include": ["src/worker", "src/config.ts"] +} diff --git a/examples/cloudflare-vite-hybrid/vite.config.ts b/examples/cloudflare-vite-hybrid/vite.config.ts new file mode 100644 index 0000000..0a83dae --- /dev/null +++ b/examples/cloudflare-vite-hybrid/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import tailwindcss from "@tailwindcss/vite"; +import { devFsVitePlugin } from "bknd/adapter/cloudflare"; + +export default defineConfig({ + plugins: [ + react(), + // this plugin provides filesystem access during development + devFsVitePlugin({ configFile: "config.ts" }) as any, + tailwindcss(), + cloudflare(), + ], + build: { + minify: true, + }, + resolve: { + dedupe: ["react", "react-dom"], + }, +}); diff --git a/examples/cloudflare-vite-hybrid/wrangler.json b/examples/cloudflare-vite-hybrid/wrangler.json new file mode 100644 index 0000000..d47fedd --- /dev/null +++ b/examples/cloudflare-vite-hybrid/wrangler.json @@ -0,0 +1,47 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-fullstack-bknd", + "main": "./src/worker/index.ts", + "compatibility_date": "2025-10-08", + "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true + }, + "upload_source_maps": true, + "assets": { + "binding": "ASSETS", + "directory": "./dist/client", + "not_found_handling": "single-page-application", + "run_worker_first": ["/api*", "!/assets/*"] + }, + "vars": { + "ENVIRONMENT": "development" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ], + "env": { + "production": { + "vars": { + "ENVIRONMENT": "production" + }, + "d1_databases": [ + { + "binding": "DB" + } + ], + "r2_buckets": [ + { + "binding": "BUCKET" + } + ] + } + } +}