Merge pull request #181 from bknd-io/feat/cf-sessions

adding d1 session support
This commit is contained in:
dswbx
2025-06-07 09:32:16 +02:00
committed by GitHub
19 changed files with 227 additions and 78 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -253,6 +253,11 @@ export class App {
break;
}
});
// call server init if set
if (this.options?.manager?.onServerInit) {
this.options.manager.onServerInit(server);
}
}
}

View File

@@ -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<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(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);

View File

@@ -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<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => {
@@ -17,6 +23,11 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
dobj?: DurableObjectNamespace;
db?: D1Database;
};
d1?: {
session?: boolean;
transport?: "header" | "cookie";
first?: D1SessionConstraint;
};
static?: "kv" | "assets";
key?: string;
keepAliveSeconds?: number;

View File

@@ -1,47 +1,148 @@
/// <reference types="@cloudflare/workers-types" />
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 extends CloudflareEnv = CloudflareEnv> = {
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<any>) {
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<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
args?: CfMakeConfigArgs<Env>,
) {
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();
});
},
},
};
}
}

View File

@@ -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<DB extends D1Database | D1DatabaseSession = D1Database> = {
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<DB>) {
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;
}

View File

@@ -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);

View File

@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
{ env, ctx, ...args }: Context<Env>,
args: Context<Env>,
) {
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<Env extends CloudflareEnv = CloudflareEnv>(
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<Env extends CloudflareEnv = CloudflareEnv>(
await config.beforeBuild?.(app);
},
},
{ env, ctx, ...args },
args,
);
if (!cachedConfig) {

View File

@@ -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<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
args?: CfMakeConfigArgs<Env>,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
@@ -23,7 +23,7 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
await config.onBuilt?.(app);
},
},
ctx.env,
ctx,
opts,
);
}

View File

@@ -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": [

View File

@@ -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=="],

View File

@@ -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: <bookmark>" ...
```

View File

@@ -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"
}
}

View File

@@ -1,10 +1,15 @@
/// <reference types="@cloudflare/workers-types" />
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 });
});
},
});

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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": [

View File

@@ -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/*"
]
}
"workspaces": ["app", "packages/*"]
}