diff --git a/README.md b/README.md index d5070c9..d473e36 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ [![npm version](https://img.shields.io/npm/v/bknd.svg)](https://npmjs.org/package/bknd) -[![npm downloads](https://img.shields.io/npm/dm/bknd)](https://www.npmjs.com/package/bknd) ![bknd](https://raw.githubusercontent.com/bknd-io/bknd/refs/heads/main/docs/_assets/poster.png) @@ -18,14 +17,14 @@ bknd simplifies app development by providing a fully functional backend for data > and therefore full backward compatibility is not guaranteed before reaching v1.0.0. ## Size -![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/index.js?compression=gzip&label=bknd) +![gzipped size of bknd](https://img.shields.io/bundlejs/size/bknd?label=bknd) ![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/client/index.js?compression=gzip&label=bknd/client) ![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) ![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@latest/dist/ui/index.js?compression=gzip&label=bknd/ui) The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets. -Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker). +Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker). ## Motivation Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them. diff --git a/app/__test__/data/data.test.ts b/app/__test__/data/data.test.ts index 886f2aa..79b4301 100644 --- a/app/__test__/data/data.test.ts +++ b/app/__test__/data/data.test.ts @@ -110,4 +110,18 @@ describe("some tests", async () => { new EntityManager([entity, entity2], connection); }).toThrow(); }); + + test("primary uuid", async () => { + const entity = new Entity("users", [ + new PrimaryField("id", { format: "uuid" }), + new TextField("username"), + ]); + const em = new EntityManager([entity], getDummyConnection().dummyConnection); + await em.schema().sync({ force: true }); + + const mutator = em.mutator(entity); + const data = await mutator.insertOne({ username: "test" }); + expect(data.data.id).toBeDefined(); + expect(data.data.id).toBeString(); + }); }); diff --git a/app/__test__/data/specs/fields/PrimaryField.spec.ts b/app/__test__/data/specs/fields/PrimaryField.spec.ts index 6be0166..c40ee14 100644 --- a/app/__test__/data/specs/fields/PrimaryField.spec.ts +++ b/app/__test__/data/specs/fields/PrimaryField.spec.ts @@ -39,4 +39,28 @@ describe("[data] PrimaryField", async () => { expect(field.transformPersist(1)).rejects.toThrow(); expect(field.transformRetrieve(1)).toBe(1); }); + + test("format", () => { + const uuid = new PrimaryField("uuid", { format: "uuid" }); + expect(uuid.format).toBe("uuid"); + expect(uuid.fieldType).toBe("text"); + expect(uuid.getNewValue()).toBeString(); + expect(uuid.toType()).toEqual({ + required: true, + comment: undefined, + type: "Generated", + import: [{ package: "kysely", name: "Generated" }], + }); + + const integer = new PrimaryField("integer", { format: "integer" }); + expect(integer.format).toBe("integer"); + expect(integer.fieldType).toBe("integer"); + expect(integer.getNewValue()).toBeUndefined(); + expect(integer.toType()).toEqual({ + required: true, + comment: undefined, + type: "Generated", + import: [{ package: "kysely", name: "Generated" }], + }); + }); }); diff --git a/app/__test__/modules/AppAuth.spec.ts b/app/__test__/modules/AppAuth.spec.ts index ede12f6..50c38f2 100644 --- a/app/__test__/modules/AppAuth.spec.ts +++ b/app/__test__/modules/AppAuth.spec.ts @@ -153,6 +153,7 @@ describe("AppAuth", () => { }); await app.build(); + app.registerAdminController(); const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh"); // register custom route @@ -162,6 +163,10 @@ describe("AppAuth", () => { await app.server.request("/api/system/ping"); await app.server.request("/test"); + expect(spy.mock.calls.length).toBe(0); + + // admin route + await app.server.request("/"); expect(spy.mock.calls.length).toBe(1); }); diff --git a/app/package.json b/app/package.json index 350340f..fc458c8 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.13.0", + "version": "0.14.0-rc.2", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "homepage": "https://bknd.io", "repository": { @@ -61,23 +61,24 @@ "bcryptjs": "^3.0.2", "dayjs": "^1.11.13", "fast-xml-parser": "^5.0.8", - "hono": "^4.7.4", "json-schema-form-react": "^0.0.2", "json-schema-library": "10.0.0-rc7", "json-schema-to-ts": "^3.1.1", "kysely": "^0.27.6", + "hono": "^4.7.11", "lodash-es": "^4.17.21", "oauth4webapi": "^2.11.1", "object-path-immutable": "^4.1.2", "radix-ui": "^1.1.3", - "swr": "^2.3.3" + "swr": "^2.3.3", + "uuid": "^11.1.0" }, "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", "@dagrejs/dagre": "^1.1.4", - "@hono/typebox-validator": "^0.3.2", - "@hono/vite-dev-server": "^0.19.0", + "@hono/typebox-validator": "^0.3.3", + "@hono/vite-dev-server": "^0.19.1", "@hookform/resolvers": "^4.1.3", "@libsql/kysely-libsql": "^0.4.1", "@mantine/modals": "^7.17.1", @@ -99,7 +100,7 @@ "dotenv": "^16.4.7", "jotai": "^2.12.2", "jsdom": "^26.0.0", - "jsonv-ts": "^0.0.14-alpha.6", + "jsonv-ts": "^0.1.0", "kysely-d1": "^0.3.0", "open": "^10.1.0", "openapi-types": "^12.1.3", @@ -120,13 +121,14 @@ "tsc-alias": "^1.8.11", "tsup": "^8.4.0", "tsx": "^4.19.3", - "vite": "^6.2.1", + "vite": "^6.3.5", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.0.9", - "wouter": "^3.6.0" + "wouter": "^3.6.0", + "@cloudflare/workers-types": "^4.20250606.0" }, "optionalDependencies": { - "@hono/node-server": "^1.13.8" + "@hono/node-server": "^1.14.3" }, "peerDependencies": { "react": ">=19", diff --git a/app/src/App.ts b/app/src/App.ts index 639d891..956229c 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -253,6 +253,11 @@ export class App { break; } }); + + // call server init if set + if (this.options?.manager?.onServerInit) { + this.options.manager.onServerInit(server); + } } } diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts index 22449a4..0c51acb 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { makeApp } from "./modes/fresh"; -import { makeConfig } from "./config"; +import { makeConfig, type CfMakeConfigArgs } from "./config"; import { disableConsoleLog, enableConsoleLog } from "core/utils"; import { adapterTestSuite } from "adapter/adapter-test-suite"; import { bunTestRunner } from "adapter/bun/test"; @@ -23,7 +23,7 @@ describe("cf adapter", () => { { connection: { url: DB_URL }, }, - {}, + $ctx({ DB_URL }), ), ).toEqual({ connection: { url: DB_URL } }); @@ -34,15 +34,15 @@ describe("cf adapter", () => { connection: { url: env.DB_URL }, }), }, - { - DB_URL, - }, + $ctx({ DB_URL }), ), ).toEqual({ connection: { url: DB_URL } }); }); - adapterTestSuite(bunTestRunner, { - makeApp, + adapterTestSuite>(bunTestRunner, { + makeApp: async (c, a, o) => { + return await makeApp(c, { env: a } as any, o); + }, makeHandler: (c, a, o) => { return async (request: any) => { const app = await makeApp( @@ -50,7 +50,7 @@ describe("cf adapter", () => { c ?? { connection: { url: DB_URL }, }, - a, + a!, o, ); return app.fetch(request); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 8ff1d08..c78eb92 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -9,7 +9,13 @@ import { getDurable } from "./modes/durable"; import type { App } from "bknd"; import { $console } from "core"; -export type CloudflareEnv = object; +declare global { + namespace Cloudflare { + interface Env {} + } +} + +export type CloudflareEnv = Cloudflare.Env; export type CloudflareBkndConfig = RuntimeBkndConfig & { mode?: "warm" | "fresh" | "cache" | "durable"; bindings?: (args: Env) => { @@ -17,6 +23,11 @@ export type CloudflareBkndConfig = RuntimeBkndConfig & dobj?: DurableObjectNamespace; db?: D1Database; }; + d1?: { + session?: boolean; + transport?: "header" | "cookie"; + first?: D1SessionConstraint; + }; static?: "kv" | "assets"; key?: string; keepAliveSeconds?: number; diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts index 0c97293..d6e75b1 100644 --- a/app/src/adapter/cloudflare/config.ts +++ b/app/src/adapter/cloudflare/config.ts @@ -1,47 +1,148 @@ +/// + import { registerMedia } from "./storage/StorageR2Adapter"; import { getBinding } from "./bindings"; -import { D1Connection } from "./D1Connection"; +import { D1Connection } from "./connection/D1Connection"; import type { CloudflareBkndConfig, CloudflareEnv } from "."; import { App } from "bknd"; import { makeConfig as makeAdapterConfig } from "bknd/adapter"; -import type { ExecutionContext } from "hono"; +import type { Context, ExecutionContext } from "hono"; import { $console } from "core"; +import { setCookie } from "hono/cookie"; export const constants = { exec_async_event_id: "cf_register_waituntil", cache_endpoint: "/__bknd/cache", do_endpoint: "/__bknd/do", + d1_session: { + cookie: "cf_d1_session", + header: "x-cf-d1-session", + }, }; +export type CfMakeConfigArgs = { + env: Env; + ctx?: ExecutionContext; + request?: Request; +}; + +function getCookieValue(cookies: string | null, name: string) { + if (!cookies) return null; + + for (const cookie of cookies.split("; ")) { + const [key, value] = cookie.split("="); + if (key === name && value) { + return decodeURIComponent(value); + } + } + return null; +} + +export function d1SessionHelper(config: CloudflareBkndConfig) { + const headerKey = constants.d1_session.header; + const cookieKey = constants.d1_session.cookie; + const transport = config.d1?.transport; + + return { + get: (request?: Request): D1SessionBookmark | undefined => { + if (!request || !config.d1?.session) return undefined; + + if (!transport || transport === "cookie") { + const cookies = request.headers.get("Cookie"); + if (cookies) { + const cookie = getCookieValue(cookies, cookieKey); + if (cookie) { + return cookie; + } + } + } + + if (!transport || transport === "header") { + if (request.headers.has(headerKey)) { + return request.headers.get(headerKey) as any; + } + } + + return undefined; + }, + set: (c: Context, d1?: D1DatabaseSession) => { + if (!d1 || !config.d1?.session) return; + + const session = d1.getBookmark(); + if (session) { + if (!transport || transport === "header") { + c.header(headerKey, session); + } + if (!transport || transport === "cookie") { + setCookie(c, cookieKey, session, { + httpOnly: true, + secure: true, + sameSite: "Lax", + maxAge: 60 * 5, // 5 minutes + }); + } + } + }, + }; +} + let media_registered: boolean = false; export function makeConfig( config: CloudflareBkndConfig, - args: Env = {} as Env, + args?: CfMakeConfigArgs, ) { if (!media_registered) { registerMedia(args as any); media_registered = true; } - const appConfig = makeAdapterConfig(config, args); - const bindings = config.bindings?.(args); - if (!appConfig.connection) { - let db: D1Database | undefined; - if (bindings?.db) { - $console.log("Using database from bindings"); - db = bindings.db; - } else if (Object.keys(args).length > 0) { - const binding = getBinding(args, "D1Database"); - if (binding) { - $console.log(`Using database from env "${binding.key}"`); - db = binding.value; + const appConfig = makeAdapterConfig(config, args?.env); + + if (args?.env) { + const bindings = config.bindings?.(args?.env); + + const sessionHelper = d1SessionHelper(config); + const sessionId = sessionHelper.get(args.request); + let session: D1DatabaseSession | undefined; + + if (!appConfig.connection) { + let db: D1Database | undefined; + if (bindings?.db) { + $console.log("Using database from bindings"); + db = bindings.db; + } else if (Object.keys(args).length > 0) { + const binding = getBinding(args.env, "D1Database"); + if (binding) { + $console.log(`Using database from env "${binding.key}"`); + db = binding.value; + } + } + + if (db) { + if (config.d1?.session) { + session = db.withSession(sessionId ?? config.d1?.first); + appConfig.connection = new D1Connection({ binding: session }); + } else { + appConfig.connection = new D1Connection({ binding: db }); + } + } else { + throw new Error("No database connection given"); } } - if (db) { - appConfig.connection = new D1Connection({ binding: db }); - } else { - throw new Error("No database connection given"); + if (config.d1?.session) { + appConfig.options = { + ...appConfig.options, + manager: { + ...appConfig.options?.manager, + onServerInit: (server) => { + server.use(async (c, next) => { + sessionHelper.set(c, session); + await next(); + }); + }, + }, + }; } } diff --git a/app/src/adapter/cloudflare/D1Connection.ts b/app/src/adapter/cloudflare/connection/D1Connection.ts similarity index 83% rename from app/src/adapter/cloudflare/D1Connection.ts rename to app/src/adapter/cloudflare/connection/D1Connection.ts index b23febf..ddf6be8 100644 --- a/app/src/adapter/cloudflare/D1Connection.ts +++ b/app/src/adapter/cloudflare/connection/D1Connection.ts @@ -5,8 +5,8 @@ import type { QB } from "data/connection/Connection"; import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely"; import { D1Dialect } from "kysely-d1"; -export type D1ConnectionConfig = { - binding: D1Database; +export type D1ConnectionConfig = { + binding: DB; }; class CustomD1Dialect extends D1Dialect { @@ -17,22 +17,24 @@ class CustomD1Dialect extends D1Dialect { } } -export class D1Connection extends SqliteConnection { +export class D1Connection< + DB extends D1Database | D1DatabaseSession = D1Database, +> extends SqliteConnection { protected override readonly supported = { batching: true, }; - constructor(private config: D1ConnectionConfig) { + constructor(private config: D1ConnectionConfig) { const plugins = [new ParseJSONResultsPlugin()]; const kysely = new Kysely({ - dialect: new CustomD1Dialect({ database: config.binding }), + dialect: new CustomD1Dialect({ database: config.binding as D1Database }), plugins, }); super(kysely, {}, plugins); } - get client(): D1Database { + get client(): DB { return this.config.binding; } diff --git a/app/src/adapter/cloudflare/index.ts b/app/src/adapter/cloudflare/index.ts index f53f908..60e6a77 100644 --- a/app/src/adapter/cloudflare/index.ts +++ b/app/src/adapter/cloudflare/index.ts @@ -1,4 +1,4 @@ -import { D1Connection, type D1ConnectionConfig } from "./D1Connection"; +import { D1Connection, type D1ConnectionConfig } from "./connection/D1Connection"; export * from "./cloudflare-workers.adapter"; export { makeApp, getFresh } from "./modes/fresh"; @@ -12,6 +12,7 @@ export { type GetBindingType, type BindingMap, } from "./bindings"; +export { constants } from "./config"; export function d1(config: D1ConnectionConfig) { return new D1Connection(config); diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index 3685f7b..fc1d3c4 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config export async function getCached( config: CloudflareBkndConfig, - { env, ctx, ...args }: Context, + args: Context, ) { + const { env, ctx } = args; const { kv } = config.bindings?.(env)!; if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); const key = config.key ?? "app"; @@ -20,7 +21,7 @@ export async function getCached( const app = await createRuntimeApp( { - ...makeConfig(config, env), + ...makeConfig(config, args), initialConfig, onBuilt: async (app) => { registerAsyncsExecutionContext(app, ctx); @@ -41,7 +42,7 @@ export async function getCached( await config.beforeBuild?.(app); }, }, - { env, ctx, ...args }, + args, ); if (!cachedConfig) { diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index af085d6..7fb37e3 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,13 +1,13 @@ import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter"; import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index"; -import { makeConfig, registerAsyncsExecutionContext } from "../config"; +import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config"; export async function makeApp( config: CloudflareBkndConfig, - args: Env = {} as Env, + args?: CfMakeConfigArgs, opts?: RuntimeOptions, ) { - return await createRuntimeApp(makeConfig(config, args), args, opts); + return await createRuntimeApp(makeConfig(config, args), args?.env, opts); } export async function getFresh( @@ -23,7 +23,7 @@ export async function getFresh( await config.onBuilt?.(app); }, }, - ctx.env, + ctx, opts, ); } diff --git a/app/src/adapter/vite/vite.adapter.ts b/app/src/adapter/vite/vite.adapter.ts index 84d7396..c69bc1e 100644 --- a/app/src/adapter/vite/vite.adapter.ts +++ b/app/src/adapter/vite/vite.adapter.ts @@ -1,24 +1,17 @@ import { serveStatic } from "@hono/node-server/serve-static"; -import { - type DevServerOptions, - default as honoViteDevServer, -} from "@hono/vite-dev-server"; +import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import type { App } from "bknd"; -import { - type RuntimeBkndConfig, - createRuntimeApp, - type FrameworkOptions, -} from "bknd/adapter"; +import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; import { devServerConfig } from "./dev-server-config"; +import type { MiddlewareHandler } from "hono"; export type ViteEnv = NodeJS.ProcessEnv; -export type ViteBkndConfig = RuntimeBkndConfig & {}; +export type ViteBkndConfig = RuntimeBkndConfig & { + serveStatic?: false | MiddlewareHandler; +}; -export function addViteScript( - html: string, - addBkndContext: boolean = true, -) { +export function addViteScript(html: string, addBkndContext: boolean = true) { return html.replace( "", `