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/package.json b/app/package.json index 314cd65..819f2b9 100644 --- a/app/package.json +++ b/app/package.json @@ -123,7 +123,8 @@ "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.14.3" 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/tsconfig.json b/app/tsconfig.json index 8b845ec..8b6bfae 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "types": ["bun-types", "@cloudflare/workers-types"], + "types": ["bun-types"], "composite": false, "incremental": true, "module": "ESNext", @@ -30,7 +30,9 @@ "baseUrl": ".", "outDir": "./dist/types", "paths": { - "*": ["./src/*"] + "*": ["./src/*"], + "bknd": ["./src/index.ts"], + "bknd/*": ["./src/*"] } }, "include": [ diff --git a/bun.lock b/bun.lock index b576605..e45d2ae 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "devDependencies": { "@biomejs/biome": "1.9.4", "@clack/prompts": "^0.10.0", - "@cloudflare/workers-types": "^4.20240620.0", "@tsconfig/strictest": "^2.0.5", "@types/lodash-es": "^4.17.12", "bun-types": "^1.1.18", @@ -59,6 +58,7 @@ "devDependencies": { "@aws-sdk/client-s3": "^3.758.0", "@bluwy/giget-core": "^0.1.2", + "@cloudflare/workers-types": "^4.20250606.0", "@dagrejs/dagre": "^1.1.4", "@hono/typebox-validator": "^0.3.3", "@hono/vite-dev-server": "^0.19.1", @@ -522,7 +522,7 @@ "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250224.0", "", { "os": "win32", "cpu": "x64" }, "sha512-x2iF1CsmYmmPEorWb1GRpAAouX5rRjmhuHMC259ojIlozR4G0LarlB9XfmeLEvtw537Ea0kJ6SOhjvUcWzxSvA=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250310.0", "", {}, "sha512-SNE2ohlL9/VxFbcHQc28n3Nj70FiS1Ea0wrUhCXUIbR2lsr4ceRVndNxhuzhcF9EZd2UXm2wwow34RIS1mm+Mg=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250606.0", "", {}, "sha512-9T/Y/Mxe57UVzqgfjJKheiMplnStj/3CmCHlgoZNLU8JW2waRbXvpY3EEeliiYAJfeHZTjeAaKO2pCabxAoyCw=="], "@cnakazawa/watch": ["@cnakazawa/watch@1.0.4", "", { "dependencies": { "exec-sh": "^0.3.2", "minimist": "^1.2.0" }, "bin": { "watch": "cli.js" } }, "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ=="], diff --git a/docs/integration/cloudflare.mdx b/docs/integration/cloudflare.mdx index d88575e..ce4544a 100644 --- a/docs/integration/cloudflare.mdx +++ b/docs/integration/cloudflare.mdx @@ -205,4 +205,34 @@ new_classes = ["DurableBkndApp"] tag = "v2" renamed_classes = [{from = "DurableBkndApp", to = "CustomDurableBkndApp"}] deleted_classes = ["DurableBkndApp"] +``` + +## D1 Sessions (experimental) +D1 now supports to enable [global read replication](https://developers.cloudflare.com/d1/best-practices/read-replication/). This allows to reduce latency by reading from the closest region. In order for this to work, D1 has to be started from a bookmark. You can enable this behavior on bknd by setting the `d1.session` property: + +```typescript src/index.ts +import { serve } from "bknd/adapter/cloudflare"; + +export default serve({ + // currently recommended to use "fresh" mode + // otherwise consecutive requests will use the same bookmark + mode: "fresh", + // ... + d1: { + // enables D1 sessions + session: true, + // (optional) restrict the transport, options: "header" | "cookie" + // if not specified, it supports both + transport: "cookie", + // (optional) choose session constraint if not bookmark present + // options: "first-primary" | "first-unconstrained" + first: "first-primary" + } +}); +``` + +If bknd is used in a stateful user context (like in a browser), it'll automatically send the session cookie to the server to set the correct bookmark. If you need to manually set the bookmark, you can do so by setting the `x-cf-d1-session` header: + +```bash +curl -H "x-cf-d1-session: " ... ``` \ No newline at end of file diff --git a/examples/cloudflare-worker/package.json b/examples/cloudflare-worker/package.json index 9f418a6..2b8a6c3 100644 --- a/examples/cloudflare-worker/package.json +++ b/examples/cloudflare-worker/package.json @@ -9,11 +9,10 @@ }, "dependencies": { "bknd": "file:../../app", - "kysely-d1": "^0.3.0" + "kysely-d1": "^0.4.0" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240620.0", - "typescript": "^5.5.3", - "wrangler": "^4.4.0" + "typescript": "^5.8.3", + "wrangler": "^4.19.1" } } diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index b9b9e76..5435b30 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -1,10 +1,15 @@ -/// - -import { serve } from "bknd/adapter/cloudflare"; +import { type D1Connection, serve } from "bknd/adapter/cloudflare"; export default serve({ mode: "warm", + d1: { + session: true, + }, onBuilt: async (app) => { - app.modules.server.get("/custom", (c) => c.json({ hello: "world" })); + app.modules.server.get("/custom", async (c) => { + const conn = c.var.app.em.connection as D1Connection; + const res = await conn.client.prepare("select * from __bknd limit 1").all(); + return c.json({ hello: "world", res }); + }); }, }); diff --git a/examples/cloudflare-worker/tsconfig.json b/examples/cloudflare-worker/tsconfig.json index 910d0f1..9879d52 100644 --- a/examples/cloudflare-worker/tsconfig.json +++ b/examples/cloudflare-worker/tsconfig.json @@ -5,7 +5,7 @@ "jsx": "react-jsx", "module": "es2022", "moduleResolution": "Bundler", - "types": ["@cloudflare/workers-types/2023-07-01"], + "types": ["./worker-configuration.d.ts"], "resolveJsonModule": true, "allowJs": true, "checkJs": false, diff --git a/examples/cloudflare-worker/worker-configuration.d.ts b/examples/cloudflare-worker/worker-configuration.d.ts index 6f59c80..9f7bd6c 100644 --- a/examples/cloudflare-worker/worker-configuration.d.ts +++ b/examples/cloudflare-worker/worker-configuration.d.ts @@ -1,12 +1,8 @@ -// Generated by Wrangler -// After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen` - -interface Env { - DB_URL: string; - DB_TOKEN: string; -} - -declare module "__STATIC_CONTENT_MANIFEST" { - const value: string; - export default value; +// placeholder, run generation again +declare namespace Cloudflare { + interface Env { + BUCKET: R2Bucket; + DB: D1Database; + } } +interface Env extends Cloudflare.Env {} diff --git a/examples/cloudflare-worker/wrangler.json b/examples/cloudflare-worker/wrangler.json index 68b57ee..013a2dd 100644 --- a/examples/cloudflare-worker/wrangler.json +++ b/examples/cloudflare-worker/wrangler.json @@ -15,8 +15,8 @@ "d1_databases": [ { "binding": "DB", - "database_name": "bknd-cf-example", - "database_id": "7ad67953-2bbf-47fc-8696-f4517dbfe674" + "database_name": "bknd-dev-weur", + "database_id": "81d8dfcc-4eaf-4453-8f0f-8f6d463fb867" } ], "r2_buckets": [ diff --git a/package.json b/package.json index c885ad0..6339fee 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "devDependencies": { "@biomejs/biome": "1.9.4", "@clack/prompts": "^0.10.0", - "@cloudflare/workers-types": "^4.20240620.0", "@tsconfig/strictest": "^2.0.5", "@types/lodash-es": "^4.17.12", "bun-types": "^1.1.18", @@ -42,8 +41,5 @@ "engines": { "node": ">=20.0.0" }, - "workspaces": [ - "app", - "packages/*" - ] -} \ No newline at end of file + "workspaces": ["app", "packages/*"] +}