Merge branch 'main' into cp/216-fix-users-link

This commit is contained in:
cameronapak
2025-12-30 06:55:20 -06:00
402 changed files with 20979 additions and 3585 deletions

View File

@@ -4,7 +4,7 @@ import { DataApi, type DataApiOptions } from "data/api/DataApi";
import { decode } from "hono/jwt";
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
import { SystemApi } from "modules/SystemApi";
import { omitKeys } from "core/utils";
import { omitKeys } from "bknd/utils";
import type { BaseModuleApiOptions } from "modules";
export type TApiUser = SafeUser;
@@ -40,10 +40,11 @@ export type ApiOptions = {
data?: SubApiOptions<DataApiOptions>;
auth?: SubApiOptions<AuthApiOptions>;
media?: SubApiOptions<MediaApiOptions>;
credentials?: RequestCredentials;
} & (
| {
token?: string;
user?: TApiUser;
user?: TApiUser | null;
}
| {
request: Request;
@@ -67,7 +68,7 @@ export class Api {
public auth!: AuthApi;
public media!: MediaApi;
constructor(private options: ApiOptions = {}) {
constructor(public options: ApiOptions = {}) {
// only mark verified if forced
this.verified = options.verified === true;
@@ -129,29 +130,45 @@ export class Api {
} else if (this.storage) {
this.storage.getItem(this.tokenKey).then((token) => {
this.token_transport = "header";
this.updateToken(token ? String(token) : undefined);
this.updateToken(token ? String(token) : undefined, {
verified: true,
trigger: false,
});
});
}
}
/**
* Make storage async to allow async storages even if sync given
* @private
*/
private get storage() {
if (!this.options.storage) return null;
return {
getItem: async (key: string) => {
return await this.options.storage!.getItem(key);
const storage = this.options.storage;
return new Proxy(
{},
{
get(_, prop) {
return (...args: any[]) => {
const response = storage ? storage[prop](...args) : undefined;
if (response instanceof Promise) {
return response;
}
return {
// biome-ignore lint/suspicious/noThenProperty: it's a promise :)
then: (fn) => fn(response),
};
};
},
},
setItem: async (key: string, value: string) => {
return await this.options.storage!.setItem(key, value);
},
removeItem: async (key: string) => {
return await this.options.storage!.removeItem(key);
},
};
) as any;
}
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
updateToken(
token?: string,
opts?: { rebuild?: boolean; verified?: boolean; trigger?: boolean },
) {
this.token = token;
this.verified = false;
this.verified = opts?.verified === true;
if (token) {
this.user = omitKeys(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
@@ -159,21 +176,22 @@ export class Api {
this.user = undefined;
}
const emit = () => {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
}
};
if (this.storage) {
const key = this.tokenKey;
if (token) {
this.storage.setItem(key, token).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
this.storage.setItem(key, token).then(emit);
} else {
this.storage.removeItem(key).then(() => {
this.options.onAuthStateChange?.(this.getAuthState());
});
this.storage.removeItem(key).then(emit);
}
} else {
if (opts?.trigger !== false) {
this.options.onAuthStateChange?.(this.getAuthState());
emit();
}
}
@@ -182,6 +200,7 @@ export class Api {
private markAuthVerified(verfied: boolean) {
this.verified = verfied;
this.options.onAuthStateChange?.(this.getAuthState());
return this;
}
@@ -208,11 +227,6 @@ export class Api {
}
async verifyAuth() {
if (!this.token) {
this.markAuthVerified(false);
return;
}
try {
const { ok, data } = await this.auth.me();
const user = data?.user;
@@ -221,10 +235,10 @@ export class Api {
}
this.user = user;
this.markAuthVerified(true);
} catch (e) {
this.markAuthVerified(false);
this.updateToken(undefined);
} finally {
this.markAuthVerified(true);
}
}
@@ -239,6 +253,7 @@ export class Api {
headers: this.options.headers,
token_transport: this.token_transport,
verbose: this.options.verbose,
credentials: this.options.credentials,
});
}
@@ -257,10 +272,9 @@ export class Api {
this.auth = new AuthApi(
{
...baseParams,
credentials: this.options.storage ? "omit" : "include",
...this.options.auth,
onTokenUpdate: (token) => {
this.updateToken(token, { rebuild: true });
onTokenUpdate: (token, verified) => {
this.updateToken(token, { rebuild: true, verified, trigger: true });
this.options.auth?.onTokenUpdate?.(token);
},
},

View File

@@ -1,21 +1,22 @@
import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core/utils";
import { $console, McpClient } from "bknd/utils";
import { Event } from "core/events";
import type { em as prototypeEm } from "data/prototype";
import { Connection } from "data/connection/Connection";
import type { Hono } from "hono";
import {
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
ModuleManager,
type ModuleBuildContext,
type ModuleManagerOptions,
} from "modules/ModuleManager";
import { DbModuleManager } from "modules/db/DbModuleManager";
import * as SystemPermissions from "modules/permissions";
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
import { SystemController } from "modules/server/SystemController";
import type { MaybePromise } from "core/types";
import type { MaybePromise, PartialRec } from "core/types";
import type { ServerEnv } from "modules/Controller";
import type { IEmailDriver, ICacheDriver } from "core/drivers";
@@ -23,13 +24,34 @@ import type { IEmailDriver, ICacheDriver } from "core/drivers";
import { Api, type ApiOptions } from "Api";
export type AppPluginConfig = {
/**
* The name of the plugin.
*/
name: string;
/**
* The schema of the plugin.
*/
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
/**
* Called before the app is built.
*/
beforeBuild?: () => MaybePromise<void>;
/**
* Called after the app is built.
*/
onBuilt?: () => MaybePromise<void>;
/**
* Called when the server is initialized.
*/
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
onFirstBoot?: () => MaybePromise<void>;
/**
* Called when the app is booted.
*/
onBoot?: () => MaybePromise<void>;
/**
* Called when the app is first booted.
*/
onFirstBoot?: () => MaybePromise<void>;
};
export type AppPlugin = (app: App) => AppPluginConfig;
@@ -72,20 +94,23 @@ export type AppOptions = {
email?: IEmailDriver;
cache?: ICacheDriver;
};
mode?: "db" | "code";
readonly?: boolean;
};
export type CreateAppConfig = {
/**
* bla
*/
connection?: Connection | { url: string };
initialConfig?: InitialModuleConfigs;
config?: PartialRec<ModuleConfigs>;
options?: AppOptions;
};
export type AppConfig = InitialModuleConfigs;
export type AppConfig = { version: number } & ModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
export class App<
C extends Connection = Connection,
Config extends PartialRec<ModuleConfigs> = PartialRec<ModuleConfigs>,
Options extends AppOptions = AppOptions,
> {
static readonly Events = AppEvents;
modules: ModuleManager;
@@ -96,11 +121,12 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
private trigger_first_boot = false;
private _building: boolean = false;
private _systemController: SystemController | null = null;
constructor(
public connection: C,
_initialConfig?: InitialModuleConfigs,
private options?: Options,
_config?: Config,
public options?: Options,
) {
this.drivers = options?.drivers ?? {};
@@ -112,9 +138,13 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.plugins.set(config.name, config);
}
this.runPlugins("onBoot");
this.modules = new ModuleManager(connection, {
// use db manager by default
const Manager = this.mode === "db" ? DbModuleManager : ModuleManager;
this.modules = new Manager(connection, {
...(options?.manager ?? {}),
initial: _initialConfig,
initial: _config,
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
@@ -123,6 +153,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this.modules.ctx().emgr.registerEvents(AppEvents);
}
get mode() {
return this.options?.mode ?? "db";
}
isReadOnly() {
return Boolean(this.mode === "code" || this.options?.readonly);
}
get emgr() {
return this.modules.ctx().emgr;
}
@@ -153,7 +191,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return results as any;
}
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
async build(options?: { sync?: boolean; forceBuild?: boolean; [key: string]: any }) {
// prevent multiple concurrent builds
if (this._building) {
while (this._building) {
@@ -166,13 +204,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = true;
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build({ fetch: options?.fetch });
await this.modules.build();
const { guard, server } = this.modules.ctx();
const { guard } = this.modules.ctx();
// load system controller
guard.registerPermissions(Object.values(SystemPermissions));
server.route("/api/system", new SystemController(this).getController());
this._systemController = new SystemController(this);
this._systemController.register(this);
// emit built event
$console.log("App built");
@@ -192,10 +231,6 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
this._building = false;
}
mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.mutateConfigSafe(module);
}
get server() {
return this.modules.server;
}
@@ -204,7 +239,14 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return this.modules.ctx().em;
}
get mcp() {
return this._systemController?._mcpServer;
}
get fetch(): Hono["fetch"] {
if (!this.isBuilt()) {
console.error("App is not built yet, run build() first");
}
return this.server.fetch as any;
}
@@ -253,6 +295,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return this.module.auth.createUser(p);
}
// @todo: potentially add option to clone the app, so that when used in listeners, it won't trigger listeners
getApi(options?: LocalApiOptions) {
const fetcher = this.server.request as typeof fetch;
if (options && options instanceof Request) {
@@ -262,6 +305,19 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
}
getMcpClient() {
const config = this.modules.get("server").config.mcp;
if (!config.enabled) {
throw new Error("MCP is not enabled");
}
const url = new URL(config.path, "http://localhost").toString();
return new McpClient({
url,
fetch: this.server.request,
});
}
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
@@ -330,6 +386,7 @@ export class App<C extends Connection = Connection, Options extends AppOptions =
}
}
}
await this.options?.manager?.onModulesBuilt?.(ctx);
}
}
@@ -338,5 +395,5 @@ export function createApp(config: CreateAppConfig = {}) {
throw new Error("Invalid connection");
}
return new App(config.connection, config.initialConfig, config.options);
return new App(config.connection, config.config, config.options);
}

View File

@@ -1,6 +1,7 @@
import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { BkndConfig, DefaultArgs } from "./index";
import type { App } from "App";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
export function adapterTestSuite<
Config extends BkndConfig = BkndConfig,
@@ -13,24 +14,17 @@ export function adapterTestSuite<
label = "app",
overrides = {},
}: {
makeApp: (
config: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => Promise<App>;
makeHandler?: (
config?: Config,
args?: Args,
opts?: RuntimeOptions | FrameworkOptions,
) => (request: Request) => Promise<Response>;
makeApp: (config: Config, args?: Args) => Promise<App>;
makeHandler?: (config?: Config, args?: Args) => (request: Request) => Promise<Response>;
label?: string;
overrides?: {
dbUrl?: string;
};
},
) {
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
const { test, expect, mock, beforeAll, afterAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
@@ -39,7 +33,7 @@ export function adapterTestSuite<
const config = {
app: (env) => ({
connection: { url: env.url },
initialConfig: {
config: {
server: { cors: { origin: env.origin } },
},
}),
@@ -53,11 +47,10 @@ export function adapterTestSuite<
url: overrides.dbUrl ?? ":memory:",
origin: "localhost",
} as any,
{ id },
);
expect(app).toBeDefined();
expect(app.toJSON().server.cors.origin).toEqual("localhost");
expect(beforeBuild).toHaveBeenCalledTimes(1);
expect(beforeBuild).toHaveBeenCalledTimes(2);
expect(onBuilt).toHaveBeenCalledTimes(1);
});
@@ -68,7 +61,7 @@ export function adapterTestSuite<
return { res, data };
};
test("responds with the same app id", async () => {
/* test.skip("responds with the same app id", async () => {
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
@@ -77,14 +70,14 @@ export function adapterTestSuite<
expect(data.server.cors.origin).toEqual("localhost");
});
test("creates fresh & responds to api config", async () => {
test.skip("creates fresh & responds to api config", async () => {
// set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const fetcher = makeHandler(undefined, undefined, { id });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
});
}); */
}
}

View File

@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
makeHandler: (c, a) => (request: Request) => astro.serve(c, a)({ request }),
});
});

View File

@@ -1,4 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = {
@@ -8,18 +8,16 @@ export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts: FrameworkOptions = {},
args: Env = import.meta.env as Env,
) {
return await createFrameworkApp(config, args ?? import.meta.env, opts);
return await createFrameworkApp(config, args);
}
export function serve<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = import.meta.env as Env,
) {
return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
return (await getApp(config, args)).fetch(fnArgs.request);
};
}

View File

@@ -1,7 +1,7 @@
import type { App } from "bknd";
import { handle } from "hono/aws-lambda";
import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
@@ -20,7 +20,6 @@ export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
): Promise<App> {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
@@ -57,17 +56,15 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
...additional,
},
args ?? process.env,
opts,
);
}
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => {
const app = await createApp(config, args, opts);
const app = await createApp(config, args);
return await handle(app.server)(event);
};
}

View File

@@ -11,8 +11,8 @@ describe("aws adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: awsLambda.createApp,
// @todo: add a request to lambda event translator?
makeHandler: (c, a, o) => async (request: Request) => {
const app = await awsLambda.createApp(c, a, o);
makeHandler: (c, a) => async (request: Request) => {
const app = await awsLambda.createApp(c, a);
return app.fetch(request);
},
});

View File

@@ -1,7 +1,7 @@
/// <reference types="bun-types" />
import path from "node:path";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from ".";
import { config, type App } from "bknd";
import type { ServeOptions } from "bun";
@@ -11,32 +11,33 @@ type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
export async function createApp<Env = BunEnv>(
{ distPath, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
{ distPath, serveStatic: _serveStatic, ...config }: BunBkndConfig<Env> = {},
args: Env = Bun.env as Env,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
return await createRuntimeApp(
{
serveStatic: serveStatic({ root }),
serveStatic:
_serveStatic ??
serveStatic({
root,
}),
...config,
},
args ?? (process.env as Env),
opts,
args,
);
}
export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = Bun.env as Env,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args);
}
return app.fetch(req);
};
@@ -46,17 +47,17 @@ export function serve<Env = BunEnv>(
{
distPath,
connection,
initialConfig,
config: _config,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
serveStatic,
beforeBuild,
...serveOptions
}: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = Bun.env as Env,
) {
Bun.serve({
...serveOptions,
@@ -64,16 +65,16 @@ export function serve<Env = BunEnv>(
fetch: createHandler(
{
connection,
initialConfig,
config: _config,
options,
onBuilt,
buildConfig,
adminOptions,
distPath,
serveStatic,
beforeBuild,
},
args,
opts,
),
});

View File

@@ -1,8 +1,9 @@
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { bunSqlite } from "./BunSqliteConnection";
import { bunTestRunner } from "adapter/bun/test";
import { describe } from "bun:test";
import { describe, test, mock, expect } from "bun:test";
import { Database } from "bun:sqlite";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
describe("BunSqliteConnection", () => {
connectionTestSuite(bunTestRunner, {
@@ -12,4 +13,20 @@ describe("BunSqliteConnection", () => {
}),
rawDialectDetails: [],
});
test("onCreateConnection", async () => {
const called = mock(() => null);
const conn = bunSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(Database);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(Database);
expect(called).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,40 +1,53 @@
import { Database } from "bun:sqlite";
import { genericSqlite, type GenericSqliteConnection } from "bknd";
import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { omitKeys } from "bknd/utils";
export type BunSqliteConnection = GenericSqliteConnection<Database>;
export type BunSqliteConnectionConfig = {
database: Database;
};
export type BunSqliteConnectionConfig = Omit<
GenericSqliteConnectionConfig<Database>,
"name" | "supports"
> &
({ database?: Database; url?: never } | { url?: string; database?: never });
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
let db: Database;
export function bunSqlite(config?: BunSqliteConnectionConfig) {
let db: Database | undefined;
if (config) {
if ("database" in config) {
if ("database" in config && config.database) {
db = config.database;
} else {
} else if (config.url) {
db = new Database(config.url);
}
} else {
}
if (!db) {
db = new Database(":memory:");
}
return genericSqlite("bun-sqlite", db, (utils) => {
//const fn = cache ? "query" : "prepare";
const getStmt = (sql: string) => db.prepare(sql);
return genericSqlite(
"bun-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
});
return {
db,
query: utils.buildQueryFn({
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
run: (sql, parameters) => {
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
return {
insertId: utils.parseBigInt(lastInsertRowid),
numAffectedRows: utils.parseBigInt(changes),
};
},
}),
close: () => db.close(),
};
},
omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
);
}

View File

@@ -1,3 +1,11 @@
export * from "./bun.adapter";
export * from "../node/storage";
export * from "./connection/BunSqliteConnection";
export async function writer(path: string, content: string) {
await Bun.write(path, content);
}
export async function reader(path: string) {
return await Bun.file(path).text();
}

View File

@@ -1,4 +1,4 @@
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
import { expect, test, mock, describe, beforeEach, afterEach, afterAll, beforeAll } from "bun:test";
export const bunTestRunner = {
describe,
@@ -8,4 +8,5 @@ export const bunTestRunner = {
beforeEach,
afterEach,
afterAll,
beforeAll,
};

View File

@@ -1,3 +1,5 @@
import { inspect } from "node:util";
export type BindingTypeMap = {
D1Database: D1Database;
KVNamespace: KVNamespace;
@@ -13,8 +15,9 @@ export function getBindings<T extends GetBindingType>(env: any, type: T): Bindin
for (const key in env) {
try {
if (
env[key] &&
((env[key] as any).constructor.name === type || String(env[key]) === `[object ${type}]`)
(env[key] as any).constructor.name === type ||
String(env[key]) === `[object ${type}]` ||
inspect(env[key]).includes(type)
) {
bindings.push({
key,

View File

@@ -1,10 +1,9 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig, type CfMakeConfigArgs } from "./config";
import { makeConfig, type CloudflareContext } from "./config";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter";
import { type CloudflareBkndConfig, createApp } from "./cloudflare-workers.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -18,42 +17,42 @@ describe("cf adapter", () => {
});
it("makes config", async () => {
const staticConfig = makeConfig(
const staticConfig = await makeConfig(
{
connection: { url: DB_URL },
initialConfig: { data: { basepath: DB_URL } },
config: { data: { basepath: DB_URL } },
},
$ctx({ DB_URL }),
);
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(staticConfig.connection).toBeDefined();
const dynamicConfig = makeConfig(
const dynamicConfig = await makeConfig(
{
app: (env) => ({
initialConfig: { data: { basepath: env.DB_URL } },
config: { data: { basepath: env.DB_URL } },
connection: { url: env.DB_URL },
}),
},
$ctx({ DB_URL }),
);
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.config).toEqual({ data: { basepath: DB_URL } });
expect(dynamicConfig.connection).toBeDefined();
});
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
makeApp: async (c, a, o) => {
return await makeApp(c, { env: a } as any, o);
adapterTestSuite<CloudflareBkndConfig, CloudflareContext<any>>(bunTestRunner, {
makeApp: async (c, a) => {
return await createApp(c, { env: a } as any);
},
makeHandler: (c, a, o) => {
makeHandler: (c, a) => {
console.log("args", a);
return async (request: any) => {
const app = await makeApp(
const app = await createApp(
// needs a fallback, otherwise tries to launch D1
c ?? {
connection: { url: DB_URL },
},
a!,
o,
a as any,
);
return app.fetch(request);
};

View File

@@ -3,11 +3,10 @@
import type { RuntimeBkndConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import { getFresh } from "./modes/fresh";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import type { App } from "bknd";
import { $console } from "core/utils";
import type { MaybePromise } from "bknd";
import { $console } from "bknd/utils";
import { createRuntimeApp } from "bknd/adapter";
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
declare global {
namespace Cloudflare {
@@ -17,12 +16,10 @@ declare global {
export type CloudflareEnv = Cloudflare.Env;
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Env) => {
bindings?: (args: Env) => MaybePromise<{
kv?: KVNamespace;
dobj?: DurableObjectNamespace;
db?: D1Database;
};
}>;
d1?: {
session?: boolean;
transport?: "header" | "cookie";
@@ -36,11 +33,27 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
registerMedia?: boolean | ((env: Env) => void);
};
export type Context<Env = CloudflareEnv> = {
request: Request;
env: Env;
ctx: ExecutionContext;
};
export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
ctx: Partial<CloudflareContext<Env>> = {},
) {
const appConfig = await makeConfig(config, ctx);
return await createRuntimeApp<Env>(
{
...appConfig,
onBuilt: async (app) => {
if (ctx.ctx) {
registerAsyncsExecutionContext(app, ctx?.ctx);
}
await appConfig.onBuilt?.(app);
},
},
ctx?.env,
);
}
// compatiblity
export const getFresh = createApp;
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
@@ -79,25 +92,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
}
}
const context = { request, env, ctx } as Context<Env>;
const mode = config.mode ?? "warm";
let app: App;
switch (mode) {
case "fresh":
app = await getFresh(config, context, { force: true });
break;
case "warm":
app = await getFresh(config, context);
break;
case "cache":
app = await getCached(config, context);
break;
case "durable":
return await getDurable(config, context);
default:
throw new Error(`Unknown mode ${mode}`);
}
const context = { request, env, ctx } as CloudflareContext<Env>;
const app = await createApp(config, context);
return app.fetch(request, env, ctx);
},

View File

@@ -8,8 +8,8 @@ import { getBinding } from "./bindings";
import { d1Sqlite } from "./connection/D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import type { Context, ExecutionContext } from "hono";
import { $console } from "core/utils";
import type { Context as HonoContext, ExecutionContext } from "hono";
import { $console } from "bknd/utils";
import { setCookie } from "hono/cookie";
export const constants = {
@@ -22,10 +22,10 @@ export const constants = {
},
};
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
export type CloudflareContext<Env extends CloudflareEnv = CloudflareEnv> = {
env: Env;
ctx?: ExecutionContext;
request?: Request;
ctx: ExecutionContext;
request: Request;
};
function getCookieValue(cookies: string | null, name: string) {
@@ -67,7 +67,7 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
return undefined;
},
set: (c: Context, d1?: D1DatabaseSession) => {
set: (c: HonoContext, d1?: D1DatabaseSession) => {
if (!d1 || !config.d1?.session) return;
const session = d1.getBookmark();
@@ -89,9 +89,9 @@ export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
}
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
export async function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
args?: Partial<CloudflareContext<Env>>,
) {
if (!media_registered && config.registerMedia !== false) {
if (typeof config.registerMedia === "function") {
@@ -102,7 +102,7 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args?.env);
const appConfig = await makeAdapterConfig(config, args?.env);
// if connection instance is given, don't do anything
// other than checking if D1 session is defined
@@ -115,12 +115,12 @@ export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
}
// if connection is given, try to open with unified sqlite adapter
} else if (appConfig.connection) {
appConfig.connection = sqlite(appConfig.connection);
appConfig.connection = sqlite(appConfig.connection) as any;
// if connection is not given, but env is set
// try to make D1 from bindings
} else if (args?.env) {
const bindings = config.bindings?.(args?.env);
const bindings = await config.bindings?.(args?.env);
const sessionHelper = d1SessionHelper(config);
const sessionId = sessionHelper.get(args.request);
let session: D1DatabaseSession | undefined;

View File

@@ -3,16 +3,16 @@
import { genericSqlite, type GenericSqliteConnection } from "bknd";
import type { QueryResult } from "kysely";
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
export type DoSqliteConnection = GenericSqliteConnection<DurableObjectState["storage"]["sql"]>;
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
export type D1ConnectionConfig<DB extends DurableObjecSql> =
export type DoConnectionConfig<DB extends DurableObjecSql> =
| DurableObjectState
| {
sql: DB;
};
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
export function doSqlite<DB extends DurableObjecSql>(config: DoConnectionConfig<DB>) {
const db = "sql" in config ? config.sql : config.storage.sql;
return genericSqlite(
@@ -21,7 +21,7 @@ export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<
(utils) => {
// must be async to work with the miniflare mock
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
await db.exec(sql, ...(parameters || []));
db.exec(sql, ...(parameters || []));
const mapResult = (
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,

View File

@@ -1,11 +1,12 @@
/// <reference types="@cloudflare/workers-types" />
import { describe, test, expect } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { Miniflare } from "miniflare";
import { doSqlite } from "./DoConnection";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
const script = `
import { DurableObject } from "cloudflare:workers";
@@ -40,6 +41,9 @@ export default {
}
`;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("doSqlite", async () => {
connectionTestSuite(viTestRunner, {
makeConnection: async () => {

View File

@@ -3,6 +3,10 @@ import { cacheWorkersKV } from "./cache";
import { viTestRunner } from "adapter/node/vitest";
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
import { Miniflare } from "miniflare";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("cacheWorkersKV", async () => {
beforeAll(() => {

View File

@@ -1,10 +1,14 @@
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh } from "./modes/fresh";
export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable";
export {
getFresh,
createApp,
serve,
type CloudflareEnv,
type CloudflareBkndConfig,
} from "./cloudflare-workers.adapter";
export { d1Sqlite, type D1ConnectionConfig };
export { doSqlite, type DoConnectionConfig } from "./connection/DoConnection";
export {
getBinding,
getBindings,
@@ -12,9 +16,10 @@ export {
type GetBindingType,
type BindingMap,
} from "./bindings";
export { constants } from "./config";
export { constants, makeConfig, type CloudflareContext } from "./config";
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
export { registries } from "bknd";
export { devFsVitePlugin, devFsWrite } from "./vite";
// for compatibility with old code
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(

View File

@@ -1,53 +0,0 @@
import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, constants } from "../config";
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<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";
const cachedConfig = await kv.get(key);
const initialConfig = cachedConfig ? JSON.parse(cachedConfig) : undefined;
async function saveConfig(__config: any) {
ctx.waitUntil(kv!.put(key, JSON.stringify(__config)));
}
const app = await createRuntimeApp(
{
...makeConfig(config, args),
initialConfig,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx);
app.module.server.client.get(constants.cache_endpoint, async (c) => {
await kv.delete(key);
return c.json({ message: "Cache cleared" });
});
await config.onBuilt?.(app);
},
beforeBuild: async (app) => {
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
async ({ params: { app } }) => {
saveConfig(app.toJSON(true));
},
"sync",
);
await config.beforeBuild?.(app);
},
},
args,
);
if (!cachedConfig) {
saveConfig(app.toJSON(true));
}
return app;
}

View File

@@ -1,134 +0,0 @@
import { DurableObject } from "cloudflare:workers";
import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
import { $console } from "core/utils";
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
) {
const { dobj } = config.bindings?.(ctx.env)!;
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
const key = config.key ?? "app";
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
}
const start = performance.now();
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = makeConfig(config, ctx.env);
const res = await stub.fire(ctx.request, {
config: create_config,
keepAliveSeconds: config.keepAliveSeconds,
});
const headers = new Headers(res.headers);
headers.set("X-TTDO", String(performance.now() - start));
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
export class DurableBkndApp extends DurableObject {
protected id = Math.random().toString(36).slice(2);
protected app?: App;
protected interval?: any;
async fire(
request: Request,
options: {
config: CreateAppConfig;
html?: string;
keepAliveSeconds?: number;
setAdminHtml?: boolean;
},
) {
let buildtime = 0;
if (!this.app) {
const start = performance.now();
const config = options.config;
// change protocol to websocket if libsql
if (
config?.connection &&
"type" in config.connection &&
config.connection.type === "libsql"
) {
//config.connection.config.protocol = "wss";
}
this.app = await createRuntimeApp({
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, this.ctx);
app.modules.server.get(constants.do_endpoint, async (c) => {
// @ts-ignore
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
return c.json({
id: this.id,
keepAliveSeconds: options?.keepAliveSeconds ?? 0,
colo: context.colo,
});
});
await this.onBuilt(app);
},
adminOptions: { html: options.html },
beforeBuild: async (app) => {
await this.beforeBuild(app);
},
});
buildtime = performance.now() - start;
}
if (options?.keepAliveSeconds) {
this.keepAlive(options.keepAliveSeconds);
}
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
headers.set("X-DO-ID", this.id);
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers,
});
}
async onBuilt(app: App) {}
async beforeBuild(app: App) {}
protected keepAlive(seconds: number) {
if (this.interval) {
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
// ping every 30 seconds
} else if (i % 30 === 0) {
console.log("ping");
this.app?.modules.ctx().connection.ping();
}
}, 1000);
}
}

View File

@@ -1,29 +0,0 @@
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args?: CfMakeConfigArgs<Env>,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
}
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
return await makeApp(
{
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
await config.onBuilt?.(app);
},
},
ctx,
opts,
);
}

View File

@@ -0,0 +1,101 @@
import {
d1Sqlite,
getBinding,
registerMedia,
type CloudflareBkndConfig,
type CloudflareEnv,
} from "bknd/adapter/cloudflare";
import type { GetPlatformProxyOptions, PlatformProxy } from "wrangler";
import process from "node:process";
import { $console } from "bknd/utils";
export type WithPlatformProxyOptions = {
/**
* By default, proxy is used if the PROXY environment variable is set to 1.
* You can override/force this by setting this option.
*/
useProxy?: boolean;
proxyOptions?: GetPlatformProxyOptions;
};
async function getPlatformProxy(opts?: GetPlatformProxyOptions) {
try {
const { version } = await import("wrangler/package.json", { with: { type: "json" } }).then(
(pkg) => pkg.default,
);
$console.log("Using wrangler version", version);
const { getPlatformProxy } = await import("wrangler");
return getPlatformProxy(opts);
} catch (e) {
$console.error("Failed to import wrangler", String(e));
const resolved = import.meta.resolve("wrangler");
$console.log("Wrangler resolved to", resolved);
const file = resolved?.split("/").pop();
if (file?.endsWith(".json")) {
$console.error(
"You have a `wrangler.json` in your current directory. Please change to .jsonc or .toml",
);
}
}
process.exit(1);
}
export function withPlatformProxy<Env extends CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
opts?: WithPlatformProxyOptions,
) {
const use_proxy =
typeof opts?.useProxy === "boolean" ? opts.useProxy : process.env.PROXY === "1";
let proxy: PlatformProxy | undefined;
$console.log("Using cloudflare platform proxy");
async function getEnv(env?: Env): Promise<Env> {
if (use_proxy) {
if (!proxy) {
proxy = await getPlatformProxy(opts?.proxyOptions);
process.on("exit", () => {
proxy?.dispose();
});
}
return proxy.env as unknown as Env;
}
return env || ({} as Env);
}
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;
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 || {};
},
} satisfies CloudflareBkndConfig<Env>;
}

View File

@@ -1,4 +1,4 @@
import { registries, isDebug, guessMimeType } from "bknd";
import { registries as $registries, isDebug, guessMimeType } from "bknd";
import { getBindings } from "../bindings";
import { s } from "bknd/utils";
import { StorageAdapter, type FileBody } from "bknd";
@@ -12,7 +12,10 @@ export function makeSchema(bindings: string[] = []) {
);
}
export function registerMedia(env: Record<string, any>) {
export function registerMedia(
env: Record<string, any>,
registries: typeof $registries = $registries,
) {
const r2_bindings = getBindings(env, "R2Bucket");
registries.media.register(
@@ -46,6 +49,8 @@ export function registerMedia(env: Record<string, any>) {
* @todo: add tests (bun tests won't work, need node native tests)
*/
export class StorageR2Adapter extends StorageAdapter {
public keyPrefix: string = "";
constructor(private readonly bucket: R2Bucket) {
super();
}
@@ -172,6 +177,9 @@ export class StorageR2Adapter extends StorageAdapter {
}
protected getKey(key: string) {
if (this.keyPrefix.length > 0) {
return `${this.keyPrefix}/${key}`.replace(/^\/\//, "/");
}
return key;
}

View File

@@ -3,8 +3,12 @@ import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import path from "node:path";
import { describe, afterAll, test, expect } from "vitest";
import { describe, afterAll, test, expect, beforeAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
let mf: Miniflare | undefined;
describe("StorageR2Adapter", async () => {
@@ -24,7 +28,8 @@ describe("StorageR2Adapter", async () => {
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
await adapterTestSuite(viTestRunner, adapter, file);
// miniflare doesn't support range requests
await adapterTestSuite(viTestRunner, adapter, file, { testRange: false });
});
afterAll(async () => {

View File

@@ -0,0 +1,272 @@
import type { Plugin } from "vite";
import { writeFile as nodeWriteFile } from "node:fs/promises";
import { resolve } from "node:path";
/**
* Vite plugin that provides Node.js filesystem access during development
* by injecting a polyfill into the SSR environment
*/
export function devFsVitePlugin({
verbose = false,
configFile = "bknd.config.ts",
}: {
verbose?: boolean;
configFile?: string;
} = {}): any {
let isDev = false;
let projectRoot = "";
return {
name: "dev-fs-plugin",
enforce: "pre",
configResolved(config) {
isDev = config.command === "serve";
projectRoot = config.root;
},
configureServer(server) {
if (!isDev) {
verbose && console.debug("[dev-fs-plugin] Not in dev mode, skipping");
return;
}
// Track active chunked requests
const activeRequests = new Map<
string,
{
totalChunks: number;
filename: string;
chunks: string[];
receivedChunks: number;
}
>();
// Intercept stdout to watch for our write requests
const originalStdoutWrite = process.stdout.write;
process.stdout.write = function (chunk: any, encoding?: any, callback?: any) {
const output = chunk.toString();
// Skip our own debug output
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
// @ts-ignore
// biome-ignore lint/style/noArguments: <explanation>
return originalStdoutWrite.apply(process.stdout, arguments);
}
// Track if we process any protocol messages (to suppress output)
let processedProtocolMessage = false;
// Process all start markers in this output
if (output.includes("{{DEV_FS_START}}")) {
const startMatches = [
...output.matchAll(/{{DEV_FS_START}} ([a-z0-9]+) (\d+) (.+)/g),
];
for (const startMatch of startMatches) {
const requestId = startMatch[1];
const totalChunks = Number.parseInt(startMatch[2]);
const filename = startMatch[3];
activeRequests.set(requestId, {
totalChunks,
filename,
chunks: new Array(totalChunks),
receivedChunks: 0,
});
verbose &&
console.debug(
`[dev-fs-plugin] Started request ${requestId} for ${filename} (${totalChunks} chunks)`,
);
}
processedProtocolMessage = true;
}
// Process all chunk data in this output
if (output.includes("{{DEV_FS_CHUNK}}")) {
const chunkMatches = [
...output.matchAll(/{{DEV_FS_CHUNK}} ([a-z0-9]+) (\d+) ([A-Za-z0-9+/=]+)/g),
];
for (const chunkMatch of chunkMatches) {
const requestId = chunkMatch[1];
const chunkIndex = Number.parseInt(chunkMatch[2]);
const chunkData = chunkMatch[3];
const request = activeRequests.get(requestId);
if (request) {
request.chunks[chunkIndex] = chunkData;
request.receivedChunks++;
verbose &&
console.debug(
`[dev-fs-plugin] Received chunk ${chunkIndex}/${request.totalChunks - 1} for ${request.filename} (length: ${chunkData.length})`,
);
// Validate base64 chunk
if (chunkData.length < 1000 && chunkIndex < request.totalChunks - 1) {
verbose &&
console.warn(
`[dev-fs-plugin] WARNING: Chunk ${chunkIndex} seems truncated (length: ${chunkData.length})`,
);
}
}
}
processedProtocolMessage = true;
}
// Process all end markers in this output
if (output.includes("{{DEV_FS_END}}")) {
const endMatches = [...output.matchAll(/{{DEV_FS_END}} ([a-z0-9]+)/g)];
for (const endMatch of endMatches) {
const requestId = endMatch[1];
const request = activeRequests.get(requestId);
if (request && request.receivedChunks === request.totalChunks) {
try {
// Reconstruct the base64 string
const fullBase64 = request.chunks.join("");
verbose &&
console.debug(
`[dev-fs-plugin] Reconstructed ${request.filename} - base64 length: ${fullBase64.length}`,
);
// Decode and parse
const decodedJson = atob(fullBase64);
const writeRequest = JSON.parse(decodedJson);
if (writeRequest.type === "DEV_FS_WRITE_REQUEST") {
verbose &&
console.debug(
`[dev-fs-plugin] Processing write request for ${writeRequest.filename}`,
);
// Process the write request
(async () => {
try {
const fullPath = resolve(projectRoot, writeRequest.filename);
verbose &&
console.debug(`[dev-fs-plugin] Writing to: ${fullPath}`);
await nodeWriteFile(fullPath, writeRequest.data);
verbose &&
console.debug("[dev-fs-plugin] File written successfully!");
} catch (error) {
console.error("[dev-fs-plugin] Error writing file:", error);
}
})();
// Clean up
activeRequests.delete(requestId);
return true;
}
} catch (error) {
console.error(
"[dev-fs-plugin] Error processing chunked request:",
String(error),
);
activeRequests.delete(requestId);
}
} else if (request) {
verbose &&
console.debug(
`[dev-fs-plugin] Request ${requestId} incomplete: ${request.receivedChunks}/${request.totalChunks} chunks`,
);
}
}
processedProtocolMessage = true;
}
// If we processed any protocol messages, suppress output
if (processedProtocolMessage) {
return callback ? callback() : true;
}
// @ts-ignore
// biome-ignore lint:
return originalStdoutWrite.apply(process.stdout, arguments);
};
// Restore stdout when server closes
server.httpServer?.on("close", () => {
process.stdout.write = originalStdoutWrite;
});
},
// @ts-ignore
transform(code, id, options) {
// Only transform in SSR mode during development
//if (!isDev || !options?.ssr) return;
if (!isDev) {
return;
}
// Check if this is the bknd config file
if (id.includes(configFile)) {
if (verbose) {
console.debug("[dev-fs-plugin] Transforming", configFile);
}
// Inject our filesystem polyfill at the top of the file
const polyfill = `
// Dev-fs polyfill injected by vite-plugin-dev-fs
if (typeof globalThis !== 'undefined') {
globalThis.__devFsPolyfill = {
writeFile: async (filename, data) => {
${verbose ? "console.debug('[dev-fs-polyfill] Intercepting write request for', filename);" : ""}
// Use console logging as a communication channel
// The main process will watch for this specific log pattern
const writeRequest = {
type: 'DEV_FS_WRITE_REQUEST',
filename: filename,
data: data,
timestamp: Date.now()
};
// Output as a specially formatted console message with end delimiter
// Base64 encode the JSON to avoid any control character issues
const jsonString = JSON.stringify(writeRequest);
const encodedJson = btoa(jsonString);
// Split into reasonable chunks that balance performance vs reliability
const chunkSize = 2000; // 2KB chunks - safe for most environments
const chunks = [];
for (let i = 0; i < encodedJson.length; i += chunkSize) {
chunks.push(encodedJson.slice(i, i + chunkSize));
}
const requestId = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
// Send start marker (use stdout.write to avoid console display)
process.stdout.write('{{DEV_FS_START}} ' + requestId + ' ' + chunks.length + ' ' + filename + '\\n');
// Send each chunk
chunks.forEach((chunk, index) => {
process.stdout.write('{{DEV_FS_CHUNK}} ' + requestId + ' ' + index + ' ' + chunk + '\\n');
});
// Send end marker
process.stdout.write('{{DEV_FS_END}} ' + requestId + '\\n');
return Promise.resolve();
}
};
}`;
return polyfill + code;
} else {
verbose && console.debug("[dev-fs-plugin] Not transforming", id);
}
},
} satisfies Plugin;
}
// Write function that uses the dev-fs polyfill injected by our Vite plugin
export async function devFsWrite(filename: string, data: string): Promise<void> {
try {
// Check if the dev-fs polyfill is available (injected by our Vite plugin)
if (typeof globalThis !== "undefined" && (globalThis as any).__devFsPolyfill) {
return (globalThis as any).__devFsPolyfill.writeFile(filename, data);
}
// Fallback to Node.js fs for other environments (Node.js, Bun)
const { writeFile } = await import("node:fs/promises");
return writeFile(filename, data);
} catch (error) {
console.error("[dev-fs-write] Error writing file:", error);
}
}

View File

@@ -1,25 +1,31 @@
import { config as $config, App, type CreateAppConfig, Connection, guessMimeType } from "bknd";
import {
config as $config,
App,
type CreateAppConfig,
Connection,
guessMimeType,
type MaybePromise,
registries as $registries,
type Merge,
} from "bknd";
import { $console } from "bknd/utils";
import type { Context, MiddlewareHandler, Next } from "hono";
import type { AdminControllerOptions } from "modules/server/AdminController";
import type { Manifest } from "vite";
export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0];
};
export type BkndConfig<Args = any, Additional = {}> = Merge<
CreateAppConfig & {
app?:
| Merge<Omit<BkndConfig, "app"> & Additional>
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
onBuilt?: (app: App) => MaybePromise<void>;
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
buildConfig?: Parameters<App["build"]>[0];
} & Additional
>;
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type CreateAdapterAppOptions = {
force?: boolean;
id?: string;
};
export type FrameworkOptions = CreateAdapterAppOptions;
export type RuntimeOptions = CreateAdapterAppOptions;
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string;
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
@@ -30,10 +36,10 @@ export type DefaultArgs = {
[key: string]: any;
};
export function makeConfig<Args = DefaultArgs>(
export async function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>,
args?: Args,
): CreateAppConfig {
): Promise<Omit<BkndConfig<Args>, "app">> {
let additionalConfig: CreateAppConfig = {};
const { app, ...rest } = config;
if (app) {
@@ -41,7 +47,7 @@ export function makeConfig<Args = DefaultArgs>(
if (!args) {
throw new Error("args is required when config.app is a function");
}
additionalConfig = app(args);
additionalConfig = await app(args);
} else {
additionalConfig = app;
}
@@ -50,55 +56,50 @@ export function makeConfig<Args = DefaultArgs>(
return { ...rest, ...additionalConfig };
}
// a map that contains all apps by id
const apps = new Map<string, App>();
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
config: Config = {} as Config,
args?: Args,
opts?: CreateAdapterAppOptions,
): Promise<App> {
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
const appConfig = makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: ":memory:" };
connection = sqlite(conf);
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
): Promise<{ app: App; config: BkndConfig<Args> }> {
await config.beforeBuild?.(undefined, $registries);
app = App.create(appConfig);
apps.set(id, app);
const appConfig = await makeConfig(config, args);
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
let connection: Connection | undefined;
if (Connection.isConnection(config.connection)) {
connection = config.connection;
} else {
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
const conf = appConfig.connection ?? { url: "file:data.db" };
connection = sqlite(conf) as any;
$console.info(`Using ${connection!.name} connection`, conf.url);
}
appConfig.connection = connection;
}
return app;
return {
app: App.create(appConfig),
config: appConfig,
};
}
export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {},
args?: Args,
opts?: FrameworkOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const { app, config: appConfig } = await createAdapterApp(config, args);
if (!app.isBuilt()) {
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await config.onBuilt?.(app);
await appConfig.onBuilt?.(app);
},
"sync",
);
}
await config.beforeBuild?.(app);
await appConfig.beforeBuild?.(app, $registries);
await app.build(config.buildConfig);
}
@@ -108,9 +109,8 @@ export async function createFrameworkApp<Args = DefaultArgs>(
export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args,
opts?: RuntimeOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
const { app, config: appConfig } = await createAdapterApp(config, args);
if (!app.isBuilt()) {
app.emgr.onEvent(
@@ -123,7 +123,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
await appConfig.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
@@ -131,7 +131,7 @@ export async function createRuntimeApp<Args = DefaultArgs>(
"sync",
);
await config.beforeBuild?.(app);
await appConfig.beforeBuild?.(app, $registries);
await app.build(config.buildConfig);
}
@@ -154,22 +154,33 @@ export async function createRuntimeApp<Args = DefaultArgs>(
* });
* ```
*/
export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
export function serveStaticViaImport(opts?: {
manifest?: Manifest;
appendRaw?: boolean;
package?: string;
}) {
let files: string[] | undefined;
const pkg = opts?.package ?? "bknd";
// @ts-ignore
return async (c: Context, next: Next) => {
if (!files) {
const manifest =
opts?.manifest || ((await import("bknd/dist/manifest.json")).default as Manifest);
opts?.manifest ||
((
await import(/* @vite-ignore */ `${pkg}/dist/manifest.json`, {
with: { type: "json" },
})
).default as Manifest);
files = Object.values(manifest).flatMap((asset) => [asset.file, ...(asset.css || [])]);
}
const path = c.req.path.substring(1);
if (files.includes(path)) {
try {
const content = await import(/* @vite-ignore */ `bknd/static/${path}?raw`, {
assert: { type: "text" },
const url = `${pkg}/static/${path}${opts?.appendRaw ? "?raw" : ""}`;
const content = await import(/* @vite-ignore */ url, {
with: { type: "text" },
}).then((m) => m.default);
if (content) {
@@ -181,7 +192,7 @@ export function serveStaticViaImport(opts?: { manifest?: Manifest }) {
});
}
} catch (e) {
console.error("Error serving static file:", e);
console.error(`Error serving static file "${path}":`, String(e));
return c.text("File not found", 404);
}
}

View File

@@ -1,4 +1,4 @@
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
@@ -9,10 +9,9 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
return await createFrameworkApp(config, args);
}
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -40,11 +39,10 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = process.env as Env,
) {
return async (req: Request) => {
const app = await getApp(config, args, opts);
const app = await getApp(config, args);
const request = getCleanRequest(req, cleanRequest);
return app.fetch(request);
};

View File

@@ -1,19 +1,29 @@
import { genericSqlite } from "bknd";
import {
genericSqlite,
type GenericSqliteConnection,
type GenericSqliteConnectionConfig,
} from "bknd";
import { DatabaseSync } from "node:sqlite";
import { omitKeys } from "bknd/utils";
export type NodeSqliteConnectionConfig = {
database: DatabaseSync;
};
export type NodeSqliteConnection = GenericSqliteConnection<DatabaseSync>;
export type NodeSqliteConnectionConfig = Omit<
GenericSqliteConnectionConfig<DatabaseSync>,
"name" | "supports"
> &
({ database?: DatabaseSync; url?: never } | { url?: string; database?: never });
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
let db: DatabaseSync;
export function nodeSqlite(config?: NodeSqliteConnectionConfig) {
let db: DatabaseSync | undefined;
if (config) {
if ("database" in config) {
if ("database" in config && config.database) {
db = config.database;
} else {
} else if (config.url) {
db = new DatabaseSync(config.url);
}
} else {
}
if (!db) {
db = new DatabaseSync(":memory:");
}
@@ -21,11 +31,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
"node-sqlite",
db,
(utils) => {
const getStmt = (sql: string) => {
const stmt = db.prepare(sql);
//stmt.setReadBigInts(true);
return stmt;
};
const getStmt = (sql: string) => db.prepare(sql);
return {
db,
@@ -49,6 +55,7 @@ export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }
};
},
{
...omitKeys(config ?? ({} as any), ["database", "url", "name", "supports"]),
supports: {
batching: false,
},

View File

@@ -1,8 +1,13 @@
import { nodeSqlite } from "./NodeSqliteConnection";
import { DatabaseSync } from "node:sqlite";
import { connectionTestSuite } from "data/connection/connection-test-suite";
import { describe } from "vitest";
import { describe, beforeAll, afterAll, test, expect, vi } from "vitest";
import { viTestRunner } from "../vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
import { GenericSqliteConnection } from "data/connection/sqlite/GenericSqliteConnection";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("NodeSqliteConnection", () => {
connectionTestSuite(viTestRunner, {
@@ -12,4 +17,20 @@ describe("NodeSqliteConnection", () => {
}),
rawDialectDetails: [],
});
test("onCreateConnection", async () => {
const called = vi.fn(() => null);
const conn = nodeSqlite({
onCreateConnection: (db) => {
expect(db).toBeInstanceOf(DatabaseSync);
called();
},
});
await conn.ping();
expect(conn).toBeInstanceOf(GenericSqliteConnection);
expect(conn.db).toBeInstanceOf(DatabaseSync);
expect(called).toHaveBeenCalledOnce();
});
});

View File

@@ -1,3 +1,13 @@
import { readFile, writeFile } from "node:fs/promises";
export * from "./node.adapter";
export * from "./storage";
export * from "./connection/NodeSqliteConnection";
export async function writer(path: string, content: string) {
await writeFile(path, content);
}
export async function reader(path: string) {
return await readFile(path, "utf-8");
}

View File

@@ -2,7 +2,7 @@ import path from "node:path";
import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { registerLocalMediaAdapter } from "adapter/node/storage";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { config as $config, type App } from "bknd";
import { $console } from "bknd/utils";
@@ -17,8 +17,7 @@ export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = process.env as Env,
) {
const root = path.relative(
process.cwd(),
@@ -34,21 +33,18 @@ export async function createApp<Env = NodeEnv>(
serveStatic: serveStatic({ root }),
...config,
},
// @ts-ignore
args ?? { env: process.env },
opts,
args,
);
}
export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = process.env as Env,
) {
let app: App | undefined;
return async (req: Request) => {
if (!app) {
app = await createApp(config, args ?? (process.env as Env), opts);
app = await createApp(config, args);
}
return app.fetch(req);
};
@@ -56,14 +52,13 @@ export function createHandler<Env = NodeEnv>(
export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
args: Env = process.env as Env,
) {
honoServe(
{
port,
hostname,
fetch: createHandler(config, args, opts),
fetch: createHandler(config, args),
},
(connInfo) => {
$console.log(`Server is running on http://localhost:${connInfo.port}`);

View File

@@ -2,10 +2,6 @@ import { describe, beforeAll, afterAll } from "vitest";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { viTestRunner } from "adapter/node/vitest";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(() => disableConsoleLog());
afterAll(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(viTestRunner, {

View File

@@ -80,18 +80,79 @@ export class StorageLocalAdapter extends StorageAdapter {
}
}
private parseRangeHeader(
rangeHeader: string,
fileSize: number,
): { start: number; end: number } | null {
// Parse "bytes=start-end" format
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
if (!match) return null;
const [, startStr, endStr] = match;
let start = startStr ? Number.parseInt(startStr, 10) : 0;
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
// Handle suffix-byte-range-spec (e.g., "bytes=-500")
if (!startStr && endStr) {
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
end = fileSize - 1;
}
// Validate range
if (start < 0 || end >= fileSize || start > end) {
return null;
}
return { start, end };
}
async getObject(key: string, headers: Headers): Promise<Response> {
try {
const content = await readFile(`${this.config.path}/${key}`);
const filePath = `${this.config.path}/${key}`;
const stats = await stat(filePath);
const fileSize = stats.size;
const mimeType = guessMimeType(key);
return new Response(content, {
status: 200,
headers: {
"Content-Type": mimeType || "application/octet-stream",
"Content-Length": content.length.toString(),
},
const responseHeaders = new Headers({
"Accept-Ranges": "bytes",
"Content-Type": mimeType || "application/octet-stream",
});
const rangeHeader = headers.get("range");
if (rangeHeader) {
const range = this.parseRangeHeader(rangeHeader, fileSize);
if (!range) {
// Invalid range - return 416 Range Not Satisfiable
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
return new Response("", {
status: 416,
headers: responseHeaders,
});
}
const { start, end } = range;
const content = await readFile(filePath, { encoding: null });
const chunk = content.slice(start, end + 1);
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
responseHeaders.set("Content-Length", chunk.length.toString());
return new Response(chunk, {
status: 206, // Partial Content
headers: responseHeaders,
});
} else {
// Normal request - return entire file
const content = await readFile(filePath);
responseHeaders.set("Content-Length", content.length.toString());
return new Response(content, {
status: 200,
headers: responseHeaders,
});
}
} catch (error) {
// Handle file reading errors
return new Response("", { status: 404 });

View File

@@ -1,9 +1,13 @@
import { describe } from "vitest";
import { describe, beforeAll, afterAll } from "vitest";
import { viTestRunner } from "adapter/node/vitest";
import { StorageLocalAdapter } from "adapter/node";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { readFileSync } from "node:fs";
import path from "node:path";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("StorageLocalAdapter (node)", async () => {
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");

View File

@@ -1,5 +1,5 @@
import nodeAssert from "node:assert/strict";
import { test, describe, beforeEach, afterEach } from "node:test";
import { test, describe, beforeEach, afterEach, after, before } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
@@ -99,5 +99,6 @@ export const nodeTestRunner: TestRunner = {
}),
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: () => {},
afterAll: after,
beforeAll: before,
};

View File

@@ -1,5 +1,5 @@
import type { TestFn, TestRunner, Test } from "core/test";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
import { describe, test, expect, vi, beforeEach, afterEach, afterAll, beforeAll } from "vitest";
function vitestTest(label: string, fn: TestFn, options?: any) {
return test(label, fn as any);
@@ -50,4 +50,5 @@ export const viTestRunner: TestRunner = {
beforeEach: beforeEach,
afterEach: afterEach,
afterAll: afterAll,
beforeAll: beforeAll,
};

View File

@@ -10,6 +10,6 @@ afterAll(enableConsoleLog);
describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: rr.getApp,
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
makeHandler: (c, a) => (request: Request) => rr.serve(c, a?.env)({ request }),
});
});

View File

@@ -1,5 +1,4 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = {
@@ -9,18 +8,16 @@ export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<En
export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = process.env as Env,
) {
return await createFrameworkApp(config, args ?? process.env, opts);
return await createFrameworkApp(config, args);
}
export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
args: Env = process.env as Env,
) {
return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
return (await getApp(config, args)).fetch(fnArgs.request);
};
}

View File

@@ -1,7 +1,7 @@
import { serveStatic } from "@hono/node-server/serve-static";
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 } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config";
import type { MiddlewareHandler } from "hono";
@@ -30,7 +30,6 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
async function createApp<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
env: ViteEnv = {} as ViteEnv,
opts: FrameworkOptions = {},
): Promise<App> {
registerLocalMediaAdapter();
return await createRuntimeApp(
@@ -47,18 +46,13 @@ async function createApp<ViteEnv>(
],
},
env,
opts,
);
}
export function serve<ViteEnv>(
config: ViteBkndConfig<ViteEnv> = {},
args?: ViteEnv,
opts?: FrameworkOptions,
) {
export function serve<ViteEnv>(config: ViteBkndConfig<ViteEnv> = {}, args?: ViteEnv) {
return {
async fetch(request: Request, env: any, ctx: ExecutionContext) {
const app = await createApp(config, env, opts);
const app = await createApp(config, env);
return app.fetch(request, env, ctx);
},
};

View File

@@ -1,8 +1,8 @@
import type { DB } from "bknd";
import type { DB, PrimaryFieldType } from "bknd";
import * as AuthPermissions from "auth/auth-permissions";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { PasswordStrategy } from "auth/authenticate/strategies/PasswordStrategy";
import { $console, secureRandomString, transformObject } from "core/utils";
import { $console, secureRandomString, transformObject, pickKeys } from "bknd/utils";
import type { Entity, EntityManager } from "data/entities";
import { em, entity, enumm, type FieldSchema } from "data/prototype";
import { Module } from "modules/Module";
@@ -14,6 +14,7 @@ import { usersFields } from "./auth-entities";
import { Authenticator } from "./authenticate/Authenticator";
import { Role } from "./authorize/Role";
export type UsersFields = typeof AppAuth.usersFields;
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
declare module "bknd" {
interface Users extends AppEntity, UserFieldSchema {}
@@ -60,7 +61,7 @@ export class AppAuth extends Module<AppAuthSchema> {
// register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => {
return Role.create({ name, ...role });
return Role.create(name, role);
});
this.ctx.guard.setRoles(Object.values(roles));
this.ctx.guard.setConfig(this.config.guard ?? {});
@@ -87,6 +88,7 @@ export class AppAuth extends Module<AppAuthSchema> {
super.setBuilt();
this._controller = new AuthController(this);
this._controller.registerMcp();
this.ctx.server.route(this.config.basepath, this._controller.getController());
this.ctx.guard.registerPermissions(AuthPermissions);
}
@@ -111,6 +113,19 @@ export class AppAuth extends Module<AppAuthSchema> {
return authConfigSchema;
}
getGuardContextSchema() {
const userschema = this.getUsersEntity().toSchema() as any;
return {
type: "object",
properties: {
user: {
type: "object",
properties: pickKeys(userschema.properties, this.config.jwt.fields as any),
},
},
};
}
get authenticator(): Authenticator {
this.throwIfNotBuilt();
return this._authenticator!;
@@ -176,16 +191,44 @@ export class AppAuth extends Module<AppAuthSchema> {
return created;
}
async changePassword(userId: PrimaryFieldType, newPassword: string) {
const users_entity = this.config.entity_name as "users";
const { data: user } = await this.em.repository(users_entity).findId(userId);
if (!user) {
throw new Error("User not found");
} else if (user.strategy !== "password") {
throw new Error("User is not using password strategy");
}
const togglePw = (visible: boolean) => {
const field = this.em.entity(users_entity).field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
};
const pw = this.authenticator.strategy("password" as const) as PasswordStrategy;
togglePw(true);
await this.em.mutator(users_entity).updateOne(user.id, {
strategy_value: await pw.hash(newPassword),
});
togglePw(false);
return true;
}
override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) {
return this.configDefault;
}
const strategies = this.authenticator.getStrategies();
const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()]));
return {
...this.config,
...this.authenticator.toJSON(secrets),
roles,
strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy),
...strategy.toJSON(secrets),

View File

@@ -1,6 +1,6 @@
import { AppAuth } from "auth/AppAuth";
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
import { $console } from "core/utils";
import { $console } from "bknd/utils";
import { pick } from "lodash-es";
import {
InvalidConditionsException,

View File

@@ -4,7 +4,7 @@ import type { AuthResponse, SafeUser, AuthStrategy } from "bknd";
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
export type AuthApiOptions = BaseModuleApiOptions & {
onTokenUpdate?: (token?: string) => void | Promise<void>;
onTokenUpdate?: (token?: string, verified?: boolean) => void | Promise<void>;
credentials?: "include" | "same-origin" | "omit";
};
@@ -17,23 +17,19 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
}
async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: this.options.credentials,
});
const res = await this.post<AuthResponse>([strategy, "login"], input);
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
await this.options.onTokenUpdate?.(res.body.token, true);
}
return res;
}
async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: this.options.credentials,
});
const res = await this.post<AuthResponse>([strategy, "register"], input);
if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token);
await this.options.onTokenUpdate?.(res.body.token, true);
}
return res;
}
@@ -71,6 +67,11 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
}
async logout() {
await this.options.onTokenUpdate?.(undefined);
return this.get(["logout"], undefined, {
headers: {
// this way bknd detects a json request and doesn't redirect back
Accept: "application/json",
},
}).then(() => this.options.onTokenUpdate?.(undefined, true));
}
}

View File

@@ -1,11 +1,20 @@
import type { SafeUser } from "bknd";
import type { DB, SafeUser } from "bknd";
import type { AuthStrategy } from "auth/authenticate/strategies/Strategy";
import type { AppAuth } from "auth/AppAuth";
import * as AuthPermissions from "auth/auth-permissions";
import * as DataPermissions from "data/permissions";
import type { Hono } from "hono";
import { Controller, type ServerEnv } from "modules/Controller";
import { describeRoute, jsc, s, parse, InvalidSchemaError, transformObject } from "bknd/utils";
import {
describeRoute,
jsc,
s,
parse,
InvalidSchemaError,
transformObject,
mcpTool,
} from "bknd/utils";
import type { PasswordStrategy } from "auth/authenticate/strategies";
export type AuthActionResponse = {
success: boolean;
@@ -51,7 +60,10 @@ export class AuthController extends Controller {
if (create) {
hono.post(
"/create",
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
permission(AuthPermissions.createUser, {}),
permission(DataPermissions.entityCreate, {
context: (c) => ({ entity: this.auth.config.entity_name }),
}),
describeRoute({
summary: "Create a new user",
tags: ["auth"],
@@ -118,6 +130,9 @@ export class AuthController extends Controller {
summary: "Get the current user",
tags: ["auth"],
}),
mcpTool("auth_me", {
noErrorCodes: [403],
}),
auth(),
async (c) => {
const claims = c.get("auth")?.user;
@@ -159,6 +174,7 @@ export class AuthController extends Controller {
summary: "Get the available authentication strategies",
tags: ["auth"],
}),
mcpTool("auth_strategies"),
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
async (c) => {
const { include_disabled } = c.req.valid("query");
@@ -188,4 +204,116 @@ export class AuthController extends Controller {
return hono;
}
override registerMcp(): void {
const { mcp } = this.auth.ctx;
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })]);
const getUser = async (params: { id?: string | number; email?: string }) => {
let user: DB["users"] | undefined = undefined;
if (params.id) {
const { data } = await this.userRepo.findId(params.id);
user = data;
} else if (params.email) {
const { data } = await this.userRepo.findOne({ email: params.email });
user = data;
}
if (!user) {
throw new Error("User not found");
}
return user;
};
const roles = Object.keys(this.auth.config.roles ?? {});
mcp.tool(
"auth_user_create",
{
description: "Create a new user",
inputSchema: s.object({
email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
role: s
.string({
enum: roles.length > 0 ? roles : undefined,
})
.optional(),
}),
},
async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.createUser);
return c.json(await this.auth.createUser(params));
},
);
mcp.tool(
"auth_user_token",
{
description: "Get a user token",
inputSchema: s.object({
id: idType.optional(),
email: s.string({ format: "email" }).optional(),
}),
},
async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.createToken);
const user = await getUser(params);
return c.json({ user, token: await this.auth.authenticator.jwt(user) });
},
);
mcp.tool(
"auth_user_password_change",
{
description: "Change a user's password",
inputSchema: s.object({
id: idType.optional(),
email: s.string({ format: "email" }).optional(),
password: s.string({ minLength: 8 }),
}),
},
async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.changePassword);
const user = await getUser(params);
if (!(await this.auth.changePassword(user.id, params.password))) {
throw new Error("Failed to change password");
}
return c.json({ changed: true });
},
);
mcp.tool(
"auth_user_password_test",
{
description: "Test a user's password",
inputSchema: s.object({
email: s.string({ format: "email" }),
password: s.string({ minLength: 8 }),
}),
},
async (params, c) => {
await c.context.ctx().helper.granted(c, AuthPermissions.testPassword);
const pw = this.auth.authenticator.strategy("password") as PasswordStrategy;
const controller = pw.getController(this.auth.authenticator);
const res = await controller.request(
new Request("https://localhost/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: params.email,
password: params.password,
}),
}),
);
return c.json({ valid: res.ok });
},
);
}
}

View File

@@ -1,4 +1,7 @@
import { Permission } from "core/security/Permission";
import { Permission } from "auth/authorize/Permission";
export const createUser = new Permission("auth.user.create");
//export const updateUser = new Permission("auth.user.update");
export const testPassword = new Permission("auth.user.password.test");
export const changePassword = new Permission("auth.user.password.change");
export const createToken = new Permission("auth.user.token.create");

View File

@@ -1,6 +1,8 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { objectTransform, s } from "bknd/utils";
import { roleSchema } from "auth/authorize/Role";
import { objectTransform, omitKeys, pick, s } from "bknd/utils";
import { $object, $record } from "modules/mcp";
export const Strategies = {
password: {
@@ -39,13 +41,11 @@ export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth
const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(),
});
export const guardRoleSchema = s.strictObject({
permissions: s.array(s.string()).optional(),
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export const authConfigSchema = s.strictObject(
export const guardRoleSchema = roleSchema;
export const authConfigSchema = $object(
"config_auth",
{
enabled: s.boolean({ default: false }),
basepath: s.string({ default: "/api/auth" }),
@@ -53,20 +53,29 @@ export const authConfigSchema = s.strictObject(
allow_register: s.boolean({ default: true }).optional(),
jwt: jwtConfig,
cookie: cookieConfig,
strategies: s.record(strategiesSchema, {
title: "Strategies",
default: {
password: {
type: "password",
enabled: true,
config: {
hashing: "sha256",
strategies: $record(
"config_auth_strategies",
strategiesSchema,
{
title: "Strategies",
default: {
password: {
type: "password",
enabled: true,
config: {
hashing: "sha256",
},
},
},
},
}),
s.strictObject({
type: s.string(),
enabled: s.boolean({ default: true }).optional(),
config: s.object({}),
}),
),
guard: guardConfigSchema.optional(),
roles: s.record(guardRoleSchema, { default: {} }).optional(),
roles: $record("config_auth_roles", guardRoleSchema, { default: {} }).optional(),
},
{ title: "Authentication" },
);

View File

@@ -6,9 +6,8 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
import { sign, verify } from "hono/jwt";
import { type CookieOptions, serializeSigned } from "hono/utils/cookie";
import type { ServerEnv } from "modules/Controller";
import { pick } from "lodash-es";
import { InvalidConditionsException } from "auth/errors";
import { s, parse, secret, runtimeSupports, truncate, $console } from "bknd/utils";
import { s, parse, secret, runtimeSupports, truncate, $console, pickKeys } from "bknd/utils";
import type { AuthStrategy } from "./strategies/Strategy";
type Input = any; // workaround
@@ -42,7 +41,8 @@ export interface UserPool {
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
export const cookieConfig = s
.object({
.strictObject({
domain: s.string().optional(),
path: s.string({ default: "/" }),
sameSite: s.string({ enum: ["strict", "lax", "none"], default: "lax" }),
secure: s.boolean({ default: true }),
@@ -53,27 +53,24 @@ export const cookieConfig = s
pathSuccess: s.string({ default: "/" }),
pathLoggedOut: s.string({ default: "/" }),
})
.partial()
.strict();
.partial();
// @todo: maybe add a config to not allow cookie/api tokens to be used interchangably?
// see auth.integration test for further details
export const jwtConfig = s
.object(
{
// @todo: autogenerate a secret if not present. But it must be persisted from AppAuth
secret: secret({ default: "" }),
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
expires: s.number().optional(), // seconds
issuer: s.string().optional(),
fields: s.array(s.string(), { default: ["id", "email", "role"] }),
},
{
default: {},
},
)
.strict();
export const jwtConfig = s.strictObject(
{
secret: secret({ default: "" }),
alg: s.string({ enum: ["HS256", "HS384", "HS512"], default: "HS256" }).optional(),
expires: s.number().optional(), // seconds
issuer: s.string().optional(),
fields: s.array(s.string(), { default: ["id", "email", "role"] }),
},
{
default: {},
},
);
export const authenticatorConfig = s.object({
jwt: jwtConfig,
cookie: cookieConfig,
@@ -231,7 +228,7 @@ export class Authenticator<
// @todo: add jwt tests
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
const user = pick(_user, this.config.jwt.fields);
const user = pickKeys(_user, this.config.jwt.fields as any);
const payload: JWTPayload = {
...user,
@@ -257,7 +254,7 @@ export class Authenticator<
}
async safeAuthResponse(_user: User): Promise<AuthResponse> {
const user = pick(_user, this.config.jwt.fields) as SafeUser;
const user = pickKeys(_user, this.config.jwt.fields as any) as SafeUser;
return {
user,
token: await this.jwt(user),
@@ -280,7 +277,9 @@ export class Authenticator<
}
return payload as any;
} catch (e) {}
} catch (e) {
$console.debug("Authenticator jwt verify error", String(e));
}
return;
}
@@ -290,6 +289,7 @@ export class Authenticator<
return {
...cookieConfig,
domain: cookieConfig.domain ?? undefined,
expires: new Date(Date.now() + expires * 1000),
};
}
@@ -327,6 +327,31 @@ export class Authenticator<
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
}
async getAuthCookieHeader(token: string, headers = new Headers()) {
const c = {
header: (key: string, value: string) => {
headers.set(key, value);
},
};
await this.setAuthCookie(c as any, token);
return headers;
}
async removeAuthCookieHeader(headers = new Headers()) {
const c = {
header: (key: string, value: string) => {
headers.set(key, value);
},
req: {
raw: {
headers,
},
},
};
this.deleteAuthCookie(c as any);
return headers;
}
async unsafeGetAuthCookie(token: string): Promise<string | undefined> {
// this works for as long as cookieOptions.prefix is not set
return serializeSigned("auth", token, this.config.jwt.secret, this.cookieOptions);
@@ -354,7 +379,10 @@ export class Authenticator<
// @todo: move this to a server helper
isJsonRequest(c: Context): boolean {
return c.req.header("Content-Type") === "application/json";
return (
c.req.header("Content-Type") === "application/json" ||
c.req.header("Accept") === "application/json"
);
}
async getBody(c: Context) {
@@ -378,13 +406,29 @@ export class Authenticator<
}
// @todo: don't extract user from token, but from the database or cache
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
async resolveAuthFromRequest(c: Context | Request | Headers): Promise<SafeUser | undefined> {
let headers: Headers;
let is_context = false;
if (c instanceof Headers) {
headers = c;
} else if (c instanceof Request) {
headers = c.headers;
} else {
is_context = true;
try {
headers = c.req.raw.headers;
} catch (e) {
throw new Exception("Request/Headers/Context is required to resolve auth", 400);
}
}
let token: string | undefined;
if (c.req.raw.headers.has("Authorization")) {
const bearerHeader = String(c.req.header("Authorization"));
if (headers.has("Authorization")) {
const bearerHeader = String(headers.get("Authorization"));
token = bearerHeader.replace("Bearer ", "");
} else {
token = await this.getAuthCookie(c);
const context = is_context ? (c as Context) : ({ req: { raw: { headers } } } as Context);
token = await this.getAuthCookie(context);
}
if (token) {

View File

@@ -1,11 +1,10 @@
import type { User } from "bknd";
import type { Authenticator } from "auth/authenticate/Authenticator";
import { InvalidCredentialsException } from "auth/errors";
import { hash, $console } from "core/utils";
import { hash, $console, s, parse, jsc, describeRoute } from "bknd/utils";
import { Hono } from "hono";
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
import { AuthStrategy } from "./Strategy";
import { s, parse, jsc } from "bknd/utils";
const schema = s
.object({
@@ -85,51 +84,67 @@ export class PasswordStrategy extends AuthStrategy<typeof schema> {
});
const payloadSchema = this.getPayloadSchema();
hono.post("/login", jsc("query", redirectQuerySchema), async (c) => {
try {
const body = parse(payloadSchema, await authenticator.getBody(c), {
onError: (errors) => {
$console.error("Invalid login payload", [...errors]);
throw new InvalidCredentialsException();
},
});
const { redirect } = c.req.valid("query");
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
});
hono.post("/register", jsc("query", redirectQuerySchema), async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
payloadSchema,
await authenticator.getBody(c),
{
hono.post(
"/login",
describeRoute({
summary: "Login with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try {
const body = parse(payloadSchema, await authenticator.getBody(c), {
onError: (errors) => {
$console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
$console.error("Invalid login payload", [...errors]);
throw new InvalidCredentialsException();
},
},
);
});
const { redirect } = c.req.valid("query");
const profile = {
...body,
email,
strategy_value: await this.hash(password),
};
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
},
);
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
});
hono.post(
"/register",
describeRoute({
summary: "Register a new user with email and password",
tags: ["auth"],
}),
jsc("query", redirectQuerySchema),
async (c) => {
try {
const { redirect } = c.req.valid("query");
const { password, email, ...body } = parse(
payloadSchema,
await authenticator.getBody(c),
{
onError: (errors) => {
$console.error("Invalid register payload", [...errors]);
new InvalidCredentialsException();
},
},
);
const profile = {
...body,
email,
strategy_value: await this.hash(password),
};
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
redirect,
});
} catch (e) {
return authenticator.respondWithError(c, e as any);
}
},
);
return hono;
}

View File

@@ -1,9 +1,12 @@
import { Exception } from "core/errors";
import { $console, objectTransform } from "core/utils";
import { Permission } from "core/security/Permission";
import { $console, mergeObject, type s } from "bknd/utils";
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Controller";
import { Role } from "./Role";
import type { Role } from "./Role";
import { HttpStatus } from "bknd/utils";
import type { Policy, PolicySchema } from "./Policy";
import { convert, type ObjectQuery } from "core/object/query/object-query";
export type GuardUserContext = {
role?: string | null;
@@ -12,41 +15,43 @@ export type GuardUserContext = {
export type GuardConfig = {
enabled?: boolean;
context?: object;
};
export type GuardContext = Context<ServerEnv> | GuardUserContext;
export class Guard {
permissions: Permission[];
roles?: Role[];
config?: GuardConfig;
export class GuardPermissionsException extends Exception {
override name = "PermissionsException";
override code = HttpStatus.FORBIDDEN;
constructor(permissions: Permission[] = [], roles: Role[] = [], config?: GuardConfig) {
constructor(
public permission: Permission,
public policy?: Policy,
public description?: string,
) {
super(`Permission "${permission.name}" not granted`);
}
override toJSON(): any {
return {
...super.toJSON(),
description: this.description,
permission: this.permission.name,
policy: this.policy?.toJSON(),
};
}
}
export class Guard {
constructor(
public permissions: Permission<any, any, any, any>[] = [],
public roles: Role[] = [],
public config?: GuardConfig,
) {
this.permissions = permissions;
this.roles = roles;
this.config = config;
}
static create(
permissionNames: string[],
roles?: Record<
string,
{
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}
>,
config?: GuardConfig,
) {
const _roles = roles
? objectTransform(roles, ({ permissions = [], is_default, implicit_allow }, name) => {
return Role.createWithPermissionNames(name, permissions, is_default, implicit_allow);
})
: {};
const _permissions = permissionNames.map((name) => new Permission(name));
return new Guard(_permissions, Object.values(_roles), config);
}
getPermissionNames(): string[] {
return this.permissions.map((permission) => permission.name);
}
@@ -73,7 +78,7 @@ export class Guard {
return this;
}
registerPermission(permission: Permission) {
registerPermission(permission: Permission<any, any, any, any>) {
if (this.permissions.find((p) => p.name === permission.name)) {
throw new Error(`Permission ${permission.name} already exists`);
}
@@ -82,9 +87,13 @@ export class Guard {
return this;
}
registerPermissions(permissions: Record<string, Permission>);
registerPermissions(permissions: Permission[]);
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
registerPermissions(permissions: Record<string, Permission<any, any, any, any>>);
registerPermissions(permissions: Permission<any, any, any, any>[]);
registerPermissions(
permissions:
| Permission<any, any, any, any>[]
| Record<string, Permission<any, any, any, any>>,
) {
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
for (const permission of p) {
@@ -117,56 +126,216 @@ export class Guard {
return this.config?.enabled === true;
}
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) {
return true;
}
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
$console.debug("guard: checking permission", {
name,
user: { id: user?.id, role: user?.role },
});
const exists = this.permissionExists(name);
if (!exists) {
throw new Error(`Permission ${name} does not exist`);
}
const role = this.getUserRole(user);
if (!role) {
$console.debug("guard: user has no role, denying");
return false;
} else if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return true;
}
const rolePermission = role.permissions.find(
(rolePermission) => rolePermission.permission.name === name,
);
$console.debug("guard: rolePermission, allowing?", {
permission: name,
role: role.name,
allowing: !!rolePermission,
});
return !!rolePermission;
}
granted(permission: Permission | string, c?: GuardContext): boolean {
private collect(permission: Permission, c: GuardContext | undefined, context: any) {
const user = c && "get" in c ? c.get("auth")?.user : c;
return this.hasPermission(permission as any, user);
const ctx = {
...((context ?? {}) as any),
...this.config?.context,
user,
};
const exists = this.permissionExists(permission.name);
const role = this.getUserRole(user);
const rolePermission = role?.permissions.find(
(rolePermission) => rolePermission.permission.name === permission.name,
);
return {
ctx,
user,
exists,
role,
rolePermission,
};
}
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
if (!this.granted(permission, c)) {
throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403,
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
): void;
granted<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext): void;
granted<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
): void {
if (!this.isEnabled()) {
return;
}
const { ctx: _ctx, exists, role, rolePermission } = this.collect(permission, c, context);
// validate context
let ctx = Object.assign({}, _ctx);
if (permission.context) {
ctx = permission.parseContext(ctx);
}
$console.debug("guard: checking permission", {
name: permission.name,
context: ctx,
});
if (!exists) {
throw new GuardPermissionsException(
permission,
undefined,
`Permission ${permission.name} does not exist`,
);
}
if (!role) {
throw new GuardPermissionsException(permission, undefined, "User has no role");
}
if (!rolePermission) {
if (role.implicit_allow === true) {
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
return;
}
throw new GuardPermissionsException(
permission,
undefined,
`Role "${role.name}" does not have required permission`,
);
}
if (rolePermission?.policies.length > 0) {
$console.debug("guard: rolePermission has policies, checking");
// set the default effect of the role permission
let allowed = rolePermission.effect === "allow";
for (const policy of rolePermission.policies) {
$console.debug("guard: checking policy", { policy: policy.toJSON(), ctx });
// skip filter policies
if (policy.content.effect === "filter") continue;
// if condition is met, check the effect
const meets = policy.meetsCondition(ctx);
if (meets) {
$console.debug("guard: policy meets condition");
// if deny, then break early
if (policy.content.effect === "deny") {
$console.debug("guard: policy is deny, setting allowed to false");
allowed = false;
break;
// if allow, set allow but continue checking
} else if (policy.content.effect === "allow") {
allowed = true;
}
} else {
$console.debug("guard: policy does not meet condition");
}
}
if (!allowed) {
throw new GuardPermissionsException(permission, undefined, "Policy condition unmet");
}
}
$console.debug("guard allowing", {
permission: permission.name,
role: role.name,
});
}
filters<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context: PermissionContext<P>,
);
filters<P extends Permission<any, any, undefined, any>>(permission: P, c: GuardContext);
filters<P extends Permission<any, any, any, any>>(
permission: P,
c: GuardContext,
context?: PermissionContext<P>,
) {
if (!permission.isFilterable()) {
throw new GuardPermissionsException(permission, undefined, "Permission is not filterable");
}
const {
ctx: _ctx,
exists,
role,
user,
rolePermission,
} = this.collect(permission, c, context);
// validate context
let ctx = Object.assign(
{
user,
},
_ctx,
);
if (permission.context) {
ctx = permission.parseContext(ctx, {
coerceDropUnknown: false,
});
}
const filters: PolicySchema["filter"][] = [];
const policies: Policy[] = [];
if (exists && role && rolePermission && rolePermission.policies.length > 0) {
for (const policy of rolePermission.policies) {
if (policy.content.effect === "filter") {
const meets = policy.meetsCondition(ctx);
if (meets) {
policies.push(policy);
filters.push(policy.getReplacedFilter(ctx));
}
}
}
}
const filter = filters.length > 0 ? mergeObject({}, ...filters) : undefined;
return {
filters,
filter,
policies,
merge: (givenFilter: object | undefined) => {
return mergeFilters(givenFilter ?? {}, filter ?? {});
},
matches: (subject: object | object[], opts?: { throwOnError?: boolean }) => {
const subjects = Array.isArray(subject) ? subject : [subject];
if (policies.length > 0) {
for (const policy of policies) {
for (const subject of subjects) {
if (!policy.meetsFilter(subject, ctx)) {
if (opts?.throwOnError) {
throw new GuardPermissionsException(
permission,
policy,
"Policy filter not met",
);
}
return false;
}
}
}
}
return true;
},
};
}
}
export function mergeFilters(base: ObjectQuery, priority: ObjectQuery) {
const base_converted = convert(base);
const priority_converted = convert(priority);
const merged = mergeObject(base_converted, priority_converted);
// in case priority filter is also contained in base's $and, merge priority in
if ("$or" in base_converted && base_converted.$or) {
const $ors = base_converted.$or as ObjectQuery;
const priority_keys = Object.keys(priority_converted);
for (const key of priority_keys) {
if (key in $ors) {
merged.$or[key] = mergeObject($ors[key], priority_converted[key]);
}
}
}
return merged;
}

View File

@@ -0,0 +1,77 @@
import { s, type ParseOptions, parse, InvalidSchemaError, HttpStatus } from "bknd/utils";
export const permissionOptionsSchema = s
.strictObject({
description: s.string(),
filterable: s.boolean(),
})
.partial();
export type TPermission = {
name: string;
description?: string;
filterable?: boolean;
context?: any;
};
export type PermissionOptions = s.Static<typeof permissionOptionsSchema>;
export type PermissionContext<P extends Permission<any, any, any, any>> = P extends Permission<
any,
any,
infer Context,
any
>
? Context extends s.ObjectSchema
? s.Static<Context>
: never
: never;
export class InvalidPermissionContextError extends InvalidSchemaError {
override name = "InvalidPermissionContextError";
// changing to internal server error because it's an unexpected behavior
override code = HttpStatus.INTERNAL_SERVER_ERROR;
static from(e: InvalidSchemaError) {
return new InvalidPermissionContextError(e.schema, e.value, e.errors);
}
}
export class Permission<
Name extends string = string,
Options extends PermissionOptions = {},
Context extends s.ObjectSchema | undefined = undefined,
ContextValue = Context extends s.ObjectSchema ? s.Static<Context> : undefined,
> {
constructor(
public name: Name,
public options: Options = {} as Options,
public context: Context = undefined as Context,
) {}
isFilterable() {
return this.options.filterable === true;
}
parseContext(ctx: ContextValue, opts?: ParseOptions) {
// @todo: allow additional properties
if (!this.context) return ctx;
try {
return this.context ? parse(this.context!, ctx, opts) : undefined;
} catch (e) {
if (e instanceof InvalidSchemaError) {
throw InvalidPermissionContextError.from(e);
}
throw e;
}
}
toJSON() {
return {
name: this.name,
...this.options,
context: this.context,
};
}
}

View File

@@ -0,0 +1,52 @@
import { s, parse, recursivelyReplacePlaceholders } from "bknd/utils";
import * as query from "core/object/query/object-query";
export const policySchema = s
.strictObject({
description: s.string(),
condition: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
// @todo: potentially remove this, and invert from rolePermission.effect
effect: s.string({ enum: ["allow", "deny", "filter"], default: "allow" }),
filter: s.object({}).optional() as s.Schema<{}, query.ObjectQuery | undefined>,
})
.partial();
export type PolicySchema = s.Static<typeof policySchema>;
export class Policy<Schema extends PolicySchema = PolicySchema> {
public content: Schema;
constructor(content?: Schema) {
this.content = parse(policySchema, content ?? {}, {
withDefaults: true,
}) as Schema;
}
replace(context: object, vars?: Record<string, any>, fallback?: any) {
return vars
? recursivelyReplacePlaceholders(context, /^@([a-zA-Z_\.]+)$/, vars, fallback)
: context;
}
getReplacedFilter(context: object, fallback?: any) {
if (!this.content.filter) return context;
return this.replace(this.content.filter!, context, fallback);
}
meetsCondition(context: object, vars?: Record<string, any>) {
if (!this.content.condition) return true;
return query.validate(this.replace(this.content.condition!, vars), context);
}
meetsFilter(subject: object, vars?: Record<string, any>) {
if (!this.content.filter) return true;
return query.validate(this.replace(this.content.filter!, vars), subject);
}
getFiltered<Given extends any[]>(given: Given): Given {
return given.filter((item) => this.meetsFilter(item)) as Given;
}
toJSON() {
return this.content;
}
}

View File

@@ -1,10 +1,39 @@
import { Permission } from "core/security/Permission";
import { s } from "bknd/utils";
import { Permission } from "./Permission";
import { Policy, policySchema } from "./Policy";
// default effect is allow for backward compatibility
const defaultEffect = "allow";
export const rolePermissionSchema = s.strictObject({
permission: s.string(),
effect: s.string({ enum: ["allow", "deny"], default: defaultEffect }).optional(),
policies: s.array(policySchema).optional(),
});
export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>;
export const roleSchema = s.strictObject({
// @todo: remove anyOf, add migration
permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(),
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export type RoleSchema = s.Static<typeof roleSchema>;
export class RolePermission {
constructor(
public permission: Permission,
public config?: any,
public permission: Permission<any, any, any, any>,
public policies: Policy[] = [],
public effect: "allow" | "deny" = defaultEffect,
) {}
toJSON() {
return {
permission: this.permission.name,
policies: this.policies.map((p) => p.toJSON()),
effect: this.effect,
};
}
}
export class Role {
@@ -15,31 +44,23 @@ export class Role {
public implicit_allow: boolean = false,
) {}
static createWithPermissionNames(
name: string,
permissionNames: string[],
is_default: boolean = false,
implicit_allow: boolean = false,
) {
return new Role(
name,
permissionNames.map((name) => new RolePermission(new Permission(name))),
is_default,
implicit_allow,
);
static create(name: string, config: RoleSchema) {
const permissions =
config.permissions?.map((p: string | RolePermissionSchema) => {
if (typeof p === "string") {
return new RolePermission(new Permission(p), []);
}
const policies = p.policies?.map((policy) => new Policy(policy));
return new RolePermission(new Permission(p.permission), policies, p.effect);
}) ?? [];
return new Role(name, permissions, config.is_default, config.implicit_allow);
}
static create(config: {
name: string;
permissions?: string[];
is_default?: boolean;
implicit_allow?: boolean;
}) {
return new Role(
config.name,
config.permissions?.map((name) => new RolePermission(new Permission(name))) ?? [],
config.is_default,
config.implicit_allow,
);
toJSON() {
return {
permissions: this.permissions.map((p) => p.toJSON()),
is_default: this.is_default,
implicit_allow: this.implicit_allow,
};
}
}

View File

@@ -1,4 +1,3 @@
import type { Permission } from "core/security/Permission";
import { $console, patternMatch } from "bknd/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
@@ -49,7 +48,7 @@ export const auth = (options?: {
// make sure to only register once
if (authCtx.registered) {
skipped = true;
$console.warn(`auth middleware already registered for ${getPath(c)}`);
$console.debug(`auth middleware already registered for ${getPath(c)}`);
} else {
authCtx.registered = true;
@@ -67,48 +66,3 @@ export const auth = (options?: {
authCtx.resolved = false;
authCtx.user = undefined;
});
export const permission = (
permission: Permission | Permission[],
options?: {
onGranted?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => Promise<Response | void | undefined>;
},
) =>
// @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined
if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p, c))) {
returned = await options?.onGranted?.(c);
} else {
returned = await options?.onDenied?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
permissions.some((p) => guard.throwUnlessGranted(p, c));
}
}
await next();
});

View File

@@ -0,0 +1,94 @@
import type { Permission, PermissionContext } from "auth/authorize/Permission";
import { $console, threw } from "bknd/utils";
import type { Context, Hono } from "hono";
import type { RouterRoute } from "hono/types";
import { createMiddleware } from "hono/factory";
import type { ServerEnv } from "modules/Controller";
import type { MaybePromise } from "core/types";
import { GuardPermissionsException } from "auth/authorize/Guard";
function getPath(reqOrCtx: Request | Context) {
const req = reqOrCtx instanceof Request ? reqOrCtx : reqOrCtx.req.raw;
return new URL(req.url).pathname;
}
const permissionSymbol = Symbol.for("permission");
type PermissionMiddlewareOptions<P extends Permission<any, any, any, any>> = {
onGranted?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
onDenied?: (c: Context<ServerEnv>) => MaybePromise<Response | void | undefined>;
} & (P extends Permission<any, any, infer PC, any>
? PC extends undefined
? {
context?: never;
}
: {
context: (c: Context<ServerEnv>) => MaybePromise<PermissionContext<P>>;
}
: {
context?: never;
});
export function permission<P extends Permission<any, any, any, any>>(
permission: P,
options: PermissionMiddlewareOptions<P>,
) {
// @ts-ignore (middlewares do not always return)
const handler = createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app");
const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined
if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
const context = (await options?.context?.(c)) ?? ({} as any);
if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response;
if (threw(() => guard.granted(permission, c, context), GuardPermissionsException)) {
returned = await options?.onDenied?.(c);
} else {
returned = await options?.onGranted?.(c);
}
if (returned instanceof Response) {
return returned;
}
} else {
guard.granted(permission, c, context);
}
}
await next();
});
return Object.assign(handler, {
[permissionSymbol]: { permission, options },
});
}
export function getPermissionRoutes(hono: Hono<any>) {
const routes: {
route: RouterRoute;
permission: Permission;
options: PermissionMiddlewareOptions<Permission>;
}[] = [];
for (const route of hono.routes) {
if (permissionSymbol in route.handler) {
routes.push({
route,
...(route.handler[permissionSymbol] as any),
});
}
}
return routes;
}

View File

@@ -1,15 +1,45 @@
import { getDefaultConfig } from "modules/ModuleManager";
import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions } from "cli/utils/options";
import { $console } from "bknd/utils";
export const config: CliCommand = (program) => {
program
.command("config")
.description("get default config")
withConfigOptions(program.command("config"))
.description("get app config")
.option("--pretty", "pretty print")
.action((options) => {
const config = getDefaultConfig();
.option("--default", "use default config")
.option("--secrets", "include secrets in output")
.option("--out <file>", "output file")
.action(async (options) => {
let config: any = {};
// biome-ignore lint/suspicious/noConsoleLog:
console.log(options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
if (options.default) {
config = getDefaultConfig();
} else {
const app = await makeAppFromEnv(options);
const manager = app.modules;
if (options.secrets) {
$console.warn("Including secrets in output");
config = manager.toJSON(true);
} else {
config = manager.extractSecrets().configs;
}
}
config = options.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config);
console.info("");
if (options.out) {
await writeFile(options.out, config);
console.info(`Config written to ${c.cyan(options.out)}`);
} else {
console.info(JSON.parse(config));
}
process.exit(0);
});
};

View File

@@ -34,4 +34,5 @@ async function action(options: { out?: string; clean?: boolean }) {
// biome-ignore lint/suspicious/noConsoleLog:
console.log(c.green(`Assets copied to: ${c.bold(out)}`));
process.exit(0);
}

View File

@@ -10,6 +10,7 @@ import color from "picocolors";
import { overridePackageJson, updateBkndPackages } from "./npm";
import { type Template, templates, type TemplateSetupCtx } from "./templates";
import { createScoped, flush } from "cli/utils/telemetry";
import path from "node:path";
const config = {
types: {
@@ -20,6 +21,7 @@ const config = {
node: "Node.js",
bun: "Bun",
cloudflare: "Cloudflare",
deno: "Deno",
aws: "AWS Lambda",
},
framework: {
@@ -259,17 +261,19 @@ async function action(options: {
}
}
// update package name
await overridePackageJson(
(pkg) => ({
...pkg,
name: ctx.name,
}),
{ dir: ctx.dir },
);
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
// update package name if there is a package.json
if (fs.existsSync(path.resolve(ctx.dir, "package.json"))) {
await overridePackageJson(
(pkg) => ({
...pkg,
name: ctx.name,
}),
{ dir: ctx.dir },
);
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
}
{
if (template.installDeps !== false) {
const install =
options.yes ??
(await $p.confirm({

View File

@@ -93,17 +93,19 @@ export async function replacePackageJsonVersions(
}
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
const versions = {
bknd: await sysGetVersion(),
...(map ?? {}),
};
await replacePackageJsonVersions(
async (pkg) => {
if (pkg in versions) {
return versions[pkg];
}
return;
},
{ dir },
);
try {
const versions = {
bknd: await sysGetVersion(),
...(map ?? {}),
};
await replacePackageJsonVersions(
async (pkg) => {
if (pkg in versions) {
return versions[pkg];
}
return;
},
{ dir },
);
} catch (e) {}
}

View File

@@ -1,7 +1,6 @@
import * as $p from "@clack/prompts";
import { overrideJson, overridePackageJson } from "cli/commands/create/npm";
import { typewriter, wait } from "cli/utils/cli";
import { uuid } from "core/utils";
import { overrideJson } from "cli/commands/create/npm";
import { typewriter } from "cli/utils/cli";
import c from "picocolors";
import type { Template, TemplateSetupCtx } from ".";
import { exec } from "cli/utils/sys";

View File

@@ -0,0 +1,21 @@
import { overrideJson } from "cli/commands/create/npm";
import type { Template } from "cli/commands/create/templates";
import { getVersion } from "cli/utils/sys";
export const deno = {
key: "deno",
title: "Deno Basic",
integration: "deno",
description: "A basic bknd Deno server with static assets",
path: "gh:bknd-io/bknd/examples/deno",
installDeps: false,
ref: true,
setup: async (ctx) => {
const version = await getVersion();
await overrideJson(
"deno.json",
(json) => ({ ...json, links: undefined, imports: { bknd: `npm:bknd@${version}` } }),
{ dir: ctx.dir },
);
},
} satisfies Template;

View File

@@ -1,3 +1,4 @@
import { deno } from "cli/commands/create/templates/deno";
import { cloudflare } from "./cloudflare";
export type TemplateSetupCtx = {
@@ -15,6 +16,7 @@ export type Integration =
| "react-router"
| "astro"
| "aws"
| "deno"
| "custom";
type TemplateScripts = "install" | "dev" | "build" | "start";
@@ -34,6 +36,11 @@ export type Template = {
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
*/
ref?: true | string;
/**
* control whether to install dependencies automatically
* e.g. on deno, this is not needed
*/
installDeps?: boolean;
scripts?: Partial<Record<TemplateScripts, string>>;
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
@@ -90,4 +97,5 @@ export const templates: Template[] = [
path: "gh:bknd-io/bknd/examples/aws-lambda",
ref: true,
},
deno,
];

View File

@@ -40,7 +40,9 @@ const subjects = {
async function action(subject: string) {
if (subject in subjects) {
await subjects[subject]();
process.exit(0);
} else {
console.error("Invalid subject: ", subject);
process.exit(1);
}
}

View File

@@ -6,3 +6,6 @@ export { user } from "./user";
export { create } from "./create";
export { copyAssets } from "./copy-assets";
export { types } from "./types";
export { mcp } from "./mcp/mcp";
export { sync } from "./sync";
export { secrets } from "./secrets";

View File

@@ -0,0 +1,82 @@
import type { CliCommand } from "cli/types";
import { makeAppFromEnv } from "../run";
import { getSystemMcp } from "modules/mcp/system-mcp";
import { $console, stdioTransport } from "bknd/utils";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
export const mcp: CliCommand = (program) =>
withConfigOptions(program.command("mcp"))
.description("mcp server stdio transport")
.option(
"--token <token>",
"token to authenticate requests, if not provided, uses BEARER_TOKEN environment variable",
)
.option("--verbose", "verbose output")
.option("--log-level <level>", "log level")
.option("--force", "force enable mcp")
.action(action);
async function action(
options: WithConfigOptions<{
verbose?: boolean;
token?: string;
logLevel?: string;
force?: boolean;
}>,
) {
const verbose = !!options.verbose;
const __oldConsole = { ...console };
// disable console
if (!verbose) {
$console.disable();
Object.entries(console).forEach(([key]) => {
console[key] = () => null;
});
}
const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node",
});
if (!app.modules.get("server").config.mcp.enabled && !options.force) {
$console.enable();
Object.assign(console, __oldConsole);
console.error("MCP is not enabled in the config, use --force to enable it");
process.exit(1);
}
const token = options.token || process.env.BEARER_TOKEN;
const server = getSystemMcp(app);
if (verbose) {
console.info(
`\n⚙ Tools (${server.tools.length}):\n${server.tools.map((t) => `- ${t.name}`).join("\n")}\n`,
);
console.info(
`📚 Resources (${server.resources.length}):\n${server.resources.map((r) => `- ${r.name}`).join("\n")}`,
);
console.info("\nMCP server is running on STDIO transport");
}
if (options.logLevel) {
server.setLogLevel(options.logLevel as any);
}
const stdout = process.stdout;
const stdin = process.stdin;
const stderr = process.stderr;
{
using transport = stdioTransport(server, {
stdin,
stdout,
stderr,
raw: new Request("https://localhost", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}),
});
}
}

View File

@@ -1,5 +1,5 @@
import path from "node:path";
import { $console } from "core/utils";
import { $console } from "bknd/utils";
import type { MiddlewareHandler } from "hono";
import open from "open";
import { fileExists, getRelativeDistPath } from "../../utils/sys";
@@ -9,18 +9,28 @@ export const PLATFORMS = ["node", "bun"] as const;
export type Platform = (typeof PLATFORMS)[number];
export async function serveStatic(server: Platform): Promise<MiddlewareHandler> {
const onNotFound = (path: string) => {
$console.debug("Couldn't resolve static file at", path);
};
switch (server) {
case "node": {
const m = await import("@hono/node-server/serve-static");
const root = getRelativeDistPath() + "/static";
$console.debug("Serving static files from", root);
return m.serveStatic({
// somehow different for node
root: getRelativeDistPath() + "/static",
root,
onNotFound,
});
}
case "bun": {
const m = await import("hono/bun");
const root = path.resolve(getRelativeDistPath(), "static");
$console.debug("Serving static files from", root);
return m.serveStatic({
root: path.resolve(getRelativeDistPath(), "static"),
root,
onNotFound,
});
}
}
@@ -66,6 +76,9 @@ export async function getConfigPath(filePath?: string) {
const config_path = path.resolve(process.cwd(), filePath);
if (await fileExists(config_path)) {
return config_path;
} else {
$console.error(`Config file could not be resolved: ${config_path}`);
process.exit(1);
}
}

View File

@@ -2,9 +2,8 @@ import type { Config } from "@libsql/client/node";
import { StorageLocalAdapter } from "adapter/node/storage";
import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander";
import { config, type App, type CreateAppConfig } from "bknd";
import { config, type App, type CreateAppConfig, type MaybePromise, registries } from "bknd";
import dotenv from "dotenv";
import { registries } from "modules/registries";
import c from "picocolors";
import path from "node:path";
import {
@@ -17,6 +16,7 @@ import {
} from "./platform";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import { colorizeConsole, isBun } from "bknd/utils";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
const env_files = [".env", ".dev.vars"];
dotenv.config({
@@ -25,8 +25,7 @@ dotenv.config({
const is_bun = isBun();
export const run: CliCommand = (program) => {
program
.command("run")
withConfigOptions(program.command("run"))
.description("run an instance")
.addOption(
new Option("-p, --port <port>", "port to run on")
@@ -41,12 +40,6 @@ export const run: CliCommand = (program) => {
"db-token",
]),
)
.addOption(new Option("-c, --config <config>", "config file"))
.addOption(
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
"config",
),
)
.addOption(
new Option("--server <server>", "server type")
.choices(PLATFORMS)
@@ -66,7 +59,7 @@ type MakeAppConfig = {
connection?: CreateAppConfig["connection"];
server?: { platform?: Platform };
setAdminHtml?: boolean;
onBuilt?: (app: App) => Promise<void>;
onBuilt?: (app: App) => MaybePromise<void>;
};
async function makeApp(config: MakeAppConfig) {
@@ -77,21 +70,21 @@ async function makeApp(config: MakeAppConfig) {
}
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
const config = makeConfig(_config, process.env);
const config = await makeConfig(_config, process.env);
return makeApp({
...config,
server: { platform },
});
}
type RunOptions = {
type RunOptions = WithConfigOptions<{
port: number;
memory?: boolean;
config?: string;
dbUrl?: string;
server: Platform;
open?: boolean;
};
}>;
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
const configFilePath = await getConfigPath(options.config);
@@ -117,7 +110,10 @@ export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
// try to use an in-memory connection
} else if (options.memory) {
console.info("Using", c.cyan("in-memory"), "connection");
app = await makeApp({ server: { platform: options.server } });
app = await makeApp({
server: { platform: options.server },
connection: { url: ":memory:" },
});
// finally try to use env variables
} else {

View File

@@ -8,7 +8,7 @@ export const schema: CliCommand = (program) => {
.option("--pretty", "pretty print")
.action((options) => {
const schema = getDefaultSchema();
// biome-ignore lint/suspicious/noConsoleLog:
console.log(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
console.info(options.pretty ? JSON.stringify(schema, null, 2) : JSON.stringify(schema));
process.exit(0);
});
};

View File

@@ -0,0 +1,59 @@
import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
import { transformObject } from "bknd/utils";
import { Option } from "commander";
export const secrets: CliCommand = (program) => {
withConfigOptions(program.command("secrets"))
.description("get app secrets")
.option("--template", "template output without the actual secrets")
.addOption(
new Option("--format <format>", "format output").choices(["json", "env"]).default("json"),
)
.option("--out <file>", "output file")
.action(
async (
options: WithConfigOptions<{ template: string; format: "json" | "env"; out: string }>,
) => {
const app = await makeAppFromEnv(options);
const manager = app.modules;
let secrets = manager.extractSecrets().secrets;
if (options.template) {
secrets = transformObject(secrets, () => "");
}
console.info("");
if (options.out) {
if (options.format === "env") {
await writeFile(
options.out,
Object.entries(secrets)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
);
} else {
await writeFile(options.out, JSON.stringify(secrets, null, 2));
}
console.info(`Secrets written to ${c.cyan(options.out)}`);
} else {
if (options.format === "env") {
console.info(
c.cyan(
Object.entries(secrets)
.map(([key, value]) => `${key}=${value}`)
.join("\n"),
),
);
} else {
console.info(secrets);
}
}
console.info("");
process.exit(0);
},
);
};

View File

@@ -0,0 +1,66 @@
import type { CliCommand } from "../types";
import { makeAppFromEnv } from "cli/commands/run";
import { writeFile } from "node:fs/promises";
import c from "picocolors";
import { withConfigOptions } from "cli/utils/options";
export const sync: CliCommand = (program) => {
withConfigOptions(program.command("sync"))
.description("sync database")
.option("--force", "perform database syncing operations")
.option("--seed", "perform seeding operations")
.option("--drop", "include destructive DDL operations")
.option("--out <file>", "output file")
.option("--sql", "use sql output")
.action(async (options) => {
const app = await makeAppFromEnv(options);
const schema = app.em.schema();
console.info(c.dim("Checking database state..."));
const stmts = await schema.sync({ drop: options.drop });
console.info("");
if (stmts.length === 0) {
console.info(c.yellow("No changes to sync"));
process.exit(0);
}
// @todo: currently assuming parameters aren't used
const sql = stmts.map((d) => d.sql).join(";\n") + ";";
if (options.force) {
console.info(c.dim("Executing:") + "\n" + c.cyan(sql));
await schema.sync({ force: true, drop: options.drop });
console.info(`\n${c.dim(`Executed ${c.cyan(stmts.length)} statement(s)`)}`);
console.info(`${c.green("Database synced")}`);
if (options.seed) {
console.info(c.dim("\nExecuting seed..."));
const seed = app.options?.seed;
if (seed) {
await app.options?.seed?.({
...app.modules.ctx(),
app: app,
});
console.info(c.green("Seed executed"));
} else {
console.info(c.yellow("No seed function provided"));
}
}
} else {
if (options.out) {
const output = options.sql ? sql : JSON.stringify(stmts, null, 2);
await writeFile(options.out, output);
console.info(`SQL written to ${c.cyan(options.out)}`);
} else {
console.info(c.dim("DDL to execute:") + "\n" + c.cyan(sql));
console.info(
c.yellow(
"\nNo statements have been executed. Use --force to perform database syncing operations",
),
);
}
}
process.exit(0);
});
};

View File

@@ -4,34 +4,37 @@ import { makeAppFromEnv } from "cli/commands/run";
import { EntityTypescript } from "data/entities/EntityTypescript";
import { writeFile } from "cli/utils/sys";
import c from "picocolors";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
export const types: CliCommand = (program) => {
program
.command("types")
withConfigOptions(program.command("types"))
.description("generate types")
.addOption(new Option("-o, --outfile <outfile>", "output file").default("bknd-types.d.ts"))
.addOption(new Option("--no-write", "do not write to file").default(true))
.addOption(new Option("--dump", "dump types to console instead of writing to file"))
.action(action);
};
async function action({
outfile,
write,
}: {
dump,
...options
}: WithConfigOptions<{
outfile: string;
write: boolean;
}) {
dump: boolean;
}>) {
const app = await makeAppFromEnv({
server: "node",
...options,
});
await app.build();
const et = new EntityTypescript(app.em);
if (write) {
if (dump) {
console.info(et.toString());
} else {
await writeFile(outfile, et.toString());
console.info(`\nTypes written to ${c.cyan(outfile)}`);
} else {
console.info(et.toString());
}
process.exit(0);
}

View File

@@ -3,19 +3,19 @@ import {
log as $log,
password as $password,
text as $text,
select as $select,
} from "@clack/prompts";
import type { App } from "App";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { makeAppFromEnv } from "cli/commands/run";
import type { CliCommand } from "cli/types";
import { Argument } from "commander";
import { $console } from "core/utils";
import { $console, isBun } from "bknd/utils";
import c from "picocolors";
import { isBun } from "core/utils";
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
export const user: CliCommand = (program) => {
program
.command("user")
withConfigOptions(program.command("user"))
.description("create/update users, or generate a token (auth)")
.addArgument(
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
@@ -23,11 +23,18 @@ export const user: CliCommand = (program) => {
.action(action);
};
async function action(action: "create" | "update" | "token", options: any) {
async function action(action: "create" | "update" | "token", options: WithConfigOptions) {
const app = await makeAppFromEnv({
config: options.config,
dbUrl: options.dbUrl,
server: "node",
});
if (!app.module.auth.enabled) {
$log.error("Auth is not enabled");
process.exit(1);
}
switch (action) {
case "create":
await create(app, options);
@@ -42,7 +49,28 @@ async function action(action: "create" | "update" | "token", options: any) {
}
async function create(app: App, options: any) {
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const auth = app.module.auth;
let role: string | null = null;
const roles = Object.keys(auth.config.roles ?? {});
const strategy = auth.authenticator.strategy("password") as PasswordStrategy;
if (roles.length > 0) {
role = (await $select({
message: "Select role",
options: [
{
value: null,
label: "<none>",
hint: "No role will be assigned to the user",
},
...roles.map((role) => ({
value: role,
label: role,
})),
],
})) as any;
if ($isCancel(role)) process.exit(1);
}
if (!strategy) {
$log.error("Password strategy not configured");
@@ -75,19 +103,19 @@ async function create(app: App, options: any) {
const created = await app.createUser({
email,
password: await strategy.hash(password as string),
role,
});
$log.success(`Created user: ${c.cyan(created.email)}`);
process.exit(0);
} catch (e) {
$log.error("Error creating user");
$console.error(e);
process.exit(1);
}
}
async function update(app: App, options: any) {
const config = app.module.auth.toJSON(true);
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
const users_entity = config.entity_name as "users";
const em = app.modules.ctx().em;
const email = (await $text({
message: "Which user? Enter email",
@@ -100,7 +128,10 @@ async function update(app: App, options: any) {
})) as string;
if ($isCancel(email)) process.exit(1);
const { data: user } = await em.repository(users_entity).findOne({ email });
const { data: user } = await app.modules
.ctx()
.em.repository(config.entity_name as "users")
.findOne({ email });
if (!user) {
$log.error("User not found");
process.exit(1);
@@ -118,26 +149,12 @@ async function update(app: App, options: any) {
});
if ($isCancel(password)) process.exit(1);
try {
function togglePw(visible: boolean) {
const field = em.entity(users_entity).field("strategy_value")!;
field.config.hidden = !visible;
field.config.fillable = visible;
}
togglePw(true);
await app.modules
.ctx()
.em.mutator(users_entity)
.updateOne(user.id, {
strategy_value: await strategy.hash(password as string),
});
togglePw(false);
if (await app.module.auth.changePassword(user.id, password)) {
$log.success(`Updated user: ${c.cyan(user.email)}`);
} catch (e) {
process.exit(0);
} else {
$log.error("Error updating user");
$console.error(e);
process.exit(1);
}
}
@@ -173,4 +190,5 @@ async function token(app: App, options: any) {
console.log(
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
);
process.exit(0);
}

View File

@@ -7,7 +7,7 @@ import { getVersion } from "./utils/sys";
import { capture, flush, init } from "cli/utils/telemetry";
const program = new Command();
export async function main() {
async function main() {
await init();
capture("start");

View File

@@ -0,0 +1,16 @@
import { type Command, Option } from "commander";
export function withConfigOptions(program: Command) {
return program
.addOption(new Option("-c, --config <config>", "config file"))
.addOption(
new Option("--db-url <db>", "database url, can be any valid sqlite url").conflicts(
"config",
),
);
}
export type WithConfigOptions<CustomOptions = {}> = {
config?: string;
dbUrl?: string;
} & CustomOptions;

View File

@@ -1,4 +1,4 @@
import { $console } from "core/utils";
import { $console } from "bknd/utils";
import { execSync, exec as nodeExec } from "node:child_process";
import { readFile, writeFile as nodeWriteFile } from "node:fs/promises";
import path from "node:path";
@@ -26,7 +26,7 @@ export async function getVersion(_path: string = "") {
return JSON.parse(pkg).version ?? "preview";
}
} catch (e) {
console.error("Failed to resolve version");
//console.error("Failed to resolve version");
}
return "unknown";

View File

@@ -11,10 +11,12 @@ export interface AppEntity<IdType = number | string> {
export interface DB {
// make sure to make unknown as "any"
[key: string]: {
/* [key: string]: {
id: PrimaryFieldType;
[key: string]: any;
};
}; */
// @todo: that's not good, but required for admin options
[key: string]: any;
}
export const config = {

View File

@@ -1,4 +1,4 @@
import { mergeObject, type RecursivePartial } from "core/utils";
import { mergeObject, type RecursivePartial } from "bknd/utils";
import type { IEmailDriver } from "./index";
export type MailchannelsEmailOptions = {

View File

@@ -1,6 +1,6 @@
import { type Event, type EventClass, InvalidEventReturn } from "./Event";
import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener";
import { $console } from "core/utils";
import { $console } from "bknd/utils";
export type RegisterListenerConfig =
| ListenerMode
@@ -205,7 +205,17 @@ export class EventManager<
if (listener.mode === "sync") {
syncs.push(listener);
} else {
asyncs.push(async () => await listener.handler(event, listener.event.slug));
asyncs.push(async () => {
try {
await listener.handler(event, listener.event.slug);
} catch (e) {
if (this.options?.onError) {
this.options.onError(event, e);
} else {
$console.error("Error executing async listener", listener, e);
}
}
});
}
// Remove if `once` is true, otherwise keep
return !listener.once;

View File

@@ -27,7 +27,7 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
) {
this._default = deepFreeze(_schema.template({}, { withOptional: true }) as any);
this._value = deepFreeze(
parse(_schema, structuredClone(initial ?? {}), {
parse(_schema, initial ?? {}, {
withDefaults: true,
//withExtendedDefaults: true,
forceParse: this.isForceParse(),
@@ -177,7 +177,6 @@ export class SchemaObject<Schema extends TSchema = TSchema> {
this.throwIfRestricted(partial);
// overwrite arrays and primitives, only deep merge objects
// @ts-ignore
const config = set(current, path, value);

View File

@@ -1,4 +1,5 @@
import type { PrimaryFieldType } from "core/config";
import { getPath, invariant, isPlainObject } from "bknd/utils";
export type Primitive = PrimaryFieldType | string | number | boolean;
export function isPrimitive(value: any): value is Primitive {
@@ -25,6 +26,10 @@ export function exp<const Key, const Expect, CTX = any>(
valid: (v: Expect) => boolean,
validate: (e: Expect, a: unknown, ctx: CTX) => any,
): Expression<Key, Expect, CTX> {
invariant(typeof key === "string", "key must be a string");
invariant(key[0] === "$", "key must start with '$'");
invariant(typeof valid === "function", "valid must be a function");
invariant(typeof validate === "function", "validate must be a function");
return new Expression(key, valid, validate);
}
@@ -50,7 +55,7 @@ function getExpression<Exps extends Expressions>(
}
type LiteralExpressionCondition<Exps extends Expressions> = {
[key: string]: Primitive | ExpressionCondition<Exps>;
[key: string]: undefined | Primitive | ExpressionCondition<Exps>;
};
const OperandOr = "$or" as const;
@@ -67,8 +72,9 @@ function _convert<Exps extends Expressions>(
expressions: Exps,
path: string[] = [],
): FilterQuery<Exps> {
invariant(typeof $query === "object", "$query must be an object");
const ExpressionConditionKeys = expressions.map((e) => e.key);
const keys = Object.keys($query);
const keys = Object.keys($query ?? {});
const operands = [OperandOr] as const;
const newQuery: FilterQuery<Exps> = {};
@@ -83,13 +89,21 @@ function _convert<Exps extends Expressions>(
function validate(key: string, value: any, path: string[] = []) {
const exp = getExpression(expressions, key as any);
if (exp.valid(value) === false) {
throw new Error(`Invalid value at "${[...path, key].join(".")}": ${value}`);
throw new Error(
`Given value at "${[...path, key].join(".")}" is invalid, got "${JSON.stringify(value)}"`,
);
}
}
for (const [key, value] of Object.entries($query)) {
// skip undefined values
if (value === undefined) {
continue;
}
// if $or, convert each value
if (key === "$or") {
invariant(isPlainObject(value), "$or must be an object");
newQuery.$or = _convert(value, expressions, [...path, key]);
// if primitive, assume $eq
@@ -98,7 +112,7 @@ function _convert<Exps extends Expressions>(
newQuery[key] = { $eq: value };
// if object, check for expressions
} else if (typeof value === "object") {
} else if (isPlainObject(value)) {
// when object is given, check if all keys are expressions
const invalid = Object.keys(value).filter(
(f) => !ExpressionConditionKeys.includes(f as any),
@@ -112,9 +126,13 @@ function _convert<Exps extends Expressions>(
}
} else {
throw new Error(
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expressions.`,
`Invalid key(s) at "${key}": ${invalid.join(", ")}. Expected expression key: ${ExpressionConditionKeys.join(", ")}.`,
);
}
} else {
throw new Error(
`Invalid value at "${[...path, key].join(".")}", got "${JSON.stringify(value)}"`,
);
}
}
@@ -149,15 +167,19 @@ function _build<Exps extends Expressions>(
throw new Error(`Expression does not exist: "${$op}"`);
}
if (!exp.valid(expected)) {
throw new Error(`Invalid expected value at "${[...path, $op].join(".")}": ${expected}`);
throw new Error(
`Invalid value at "${[...path, $op].join(".")}", got "${JSON.stringify(expected)}"`,
);
}
return exp.validate(expected, actual, options.exp_ctx);
}
// check $and
for (const [key, value] of Object.entries($and)) {
if (value === undefined) continue;
for (const [$op, $v] of Object.entries(value)) {
const objValue = options.value_is_kv ? key : options.object[key];
const objValue = options.value_is_kv ? key : getPath(options.object, key);
result.$and.push(__validate($op, $v, objValue, [key]));
result.keys.add(key);
}
@@ -165,7 +187,7 @@ function _build<Exps extends Expressions>(
// check $or
for (const [key, value] of Object.entries($or ?? {})) {
const objValue = options.value_is_kv ? key : options.object[key];
const objValue = options.value_is_kv ? key : getPath(options.object, key);
for (const [$op, $v] of Object.entries(value)) {
result.$or.push(__validate($op, $v, objValue, [key]));
@@ -189,6 +211,10 @@ function _validate(results: ValidationResults): boolean {
}
export function makeValidator<Exps extends Expressions>(expressions: Exps) {
if (!expressions.some((e) => e.key === "$eq")) {
throw new Error("'$eq' expression is required");
}
return {
convert: (query: FilterQuery<Exps>) => _convert(query, expressions),
build: (query: FilterQuery<Exps>, options: BuildOptions) =>

View File

@@ -1,11 +0,0 @@
export class Permission<Name extends string = string> {
constructor(public name: Name) {
this.name = name;
}
toJSON() {
return {
name: this.name,
};
}
}

View File

@@ -31,6 +31,7 @@ export type TestRunner = {
beforeEach: (fn: () => MaybePromise<void>) => void;
afterEach: (fn: () => MaybePromise<void>) => void;
afterAll: (fn: () => MaybePromise<void>) => void;
beforeAll: (fn: () => MaybePromise<void>) => void;
};
export async function retry<T>(

View File

@@ -1,12 +1,37 @@
import { createApp as createAppInternal, type CreateAppConfig } from "App";
import { bunSqlite } from "adapter/bun/connection/BunSqliteConnection";
import { Connection } from "data/connection/Connection";
import { Connection, createApp as createAppInternal, type CreateAppConfig } from "bknd";
import { bunSqlite } from "bknd/adapter/bun";
import type { McpServer } from "bknd/utils";
export { App } from "App";
export { App } from "bknd";
export function createApp({ connection, ...config }: CreateAppConfig = {}) {
return createAppInternal({
...config,
connection: Connection.isConnection(connection) ? connection : bunSqlite(connection as any),
connection: Connection.isConnection(connection)
? connection
: (bunSqlite(connection as any) as any),
});
}
export function createMcpToolCaller() {
return async (server: McpServer, name: string, args: any, raw?: any) => {
const res = await server.handle(
{
jsonrpc: "2.0",
method: "tools/call",
params: {
name,
arguments: args,
},
},
raw,
);
if ((res.result as any)?.isError) {
console.dir(res.result, { depth: null });
throw new Error((res.result as any)?.content?.[0]?.text ?? "Unknown error");
}
return JSON.parse((res.result as any)?.content?.[0]?.text ?? "null");
};
}

View File

@@ -4,3 +4,9 @@ export interface Serializable<Class, Json extends object = object> {
}
export type MaybePromise<T> = T | Promise<T>;
export type PartialRec<T> = { [P in keyof T]?: PartialRec<T[P]> };
export type Merge<T> = {
[K in keyof T]: T[K];
};

View File

@@ -76,6 +76,7 @@ declare global {
| {
level: TConsoleSeverity;
id?: string;
enabled?: boolean;
}
| undefined;
}
@@ -86,6 +87,7 @@ const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity;
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
const config = (globalThis.__consoleConfig ??= {
level: defaultLevel,
enabled: true,
//id: crypto.randomUUID(), // for debugging
});
@@ -95,6 +97,14 @@ export const $console = new Proxy(config as any, {
switch (prop) {
case "original":
return console;
case "disable":
return () => {
config.enabled = false;
};
case "enable":
return () => {
config.enabled = true;
};
case "setLevel":
return (l: TConsoleSeverity) => {
config.level = l;
@@ -105,6 +115,10 @@ export const $console = new Proxy(config as any, {
};
}
if (!config.enabled) {
return () => null;
}
const current = keys.indexOf(config.level);
const requested = keys.indexOf(prop as string);
@@ -118,6 +132,8 @@ export const $console = new Proxy(config as any, {
} & {
setLevel: (l: TConsoleSeverity) => void;
resetLevel: () => void;
disable: () => void;
enable: () => void;
};
export function colorizeConsole(con: typeof console) {

View File

@@ -1,7 +1,7 @@
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
import { randomString } from "core/utils/strings";
import { randomString } from "./strings";
import type { Context } from "hono";
import { invariant } from "core/utils/runtime";
import { invariant } from "./runtime";
import { $console } from "./console";
export function getContentName(request: Request): string | undefined;
@@ -240,3 +240,46 @@ export async function blobToFile(
lastModified: Date.now(),
});
}
export function isFileAccepted(file: File | unknown, _accept: string | string[]): boolean {
const accept = Array.isArray(_accept) ? _accept.join(",") : _accept;
if (!accept || !accept.trim()) return true; // no restrictions
if (!isFile(file)) {
throw new Error("Given file is not a File instance");
}
const name = file.name.toLowerCase();
const type = (file.type || "").trim().toLowerCase();
// split on commas, trim whitespace
const tokens = accept
.split(",")
.map((t) => t.trim().toLowerCase())
.filter(Boolean);
// try each token until one matches
return tokens.some((token) => {
if (token.startsWith(".")) {
// extension match, e.g. ".png" or ".tar.gz"
return name.endsWith(token);
}
const slashIdx = token.indexOf("/");
if (slashIdx !== -1) {
const [major, minor] = token.split("/");
if (minor === "*") {
// wildcard like "image/*"
if (!type) return false;
const [fMajor] = type.split("/");
return fMajor === major;
} else {
// exact MIME like "image/svg+xml" or "application/pdf"
// because of "text/plain;charset=utf-8"
return type.startsWith(token);
}
}
// unknown token shape, ignore
return false;
});
}

View File

@@ -13,18 +13,5 @@ export * from "./uuid";
export * from "./test";
export * from "./runtime";
export * from "./numbers";
export {
s,
stripMark,
mark,
stringIdentifier,
SecretSchema,
secret,
parse,
jsc,
describeRoute,
schemaToSpec,
openAPISpecs,
type ParseOptions,
InvalidSchemaError,
} from "./schema";
export * from "./schema";
export { DebugLogger } from "./DebugLogger";

View File

@@ -14,10 +14,10 @@ export function ensureInt(value?: string | number | null | undefined): number {
export const formatNumber = {
fileSize: (bytes: number, decimals = 2): string => {
if (bytes === 0) return "0 Bytes";
if (bytes === 0) return "0 B";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + " " + sizes[i];
},

View File

@@ -26,6 +26,20 @@ export function omitKeys<T extends object, K extends keyof T>(
return result;
}
export function pickKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[],
): Pick<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const result = {} as Pick<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (keys.has(key as K)) {
(result as any)[key] = value;
}
}
return result;
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => {
try {
@@ -189,6 +203,30 @@ export function objectDepth(object: object): number {
return level;
}
export function limitObjectDepth<T>(obj: T, maxDepth: number): T {
function _limit(current: any, depth: number): any {
if (isPlainObject(current)) {
if (depth > maxDepth) {
return undefined;
}
const result: any = {};
for (const key in current) {
if (Object.prototype.hasOwnProperty.call(current, key)) {
result[key] = _limit(current[key], depth + 1);
}
}
return result;
}
if (Array.isArray(current)) {
// Arrays themselves are not limited, but their object elements are
return current.map((item) => _limit(item, depth));
}
// Primitives are always returned, regardless of depth
return current;
}
return _limit(obj, 1);
}
export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj): Obj {
if (!obj) return obj;
return Object.entries(obj).reduce((acc, [key, value]) => {
@@ -334,7 +372,7 @@ export function isEqual(value1: any, value2: any): boolean {
export function getPath(
object: object,
_path: string | (string | number)[],
defaultValue = undefined,
defaultValue: any = undefined,
): any {
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
@@ -358,6 +396,38 @@ export function getPath(
}
}
export function setPath(object: object, _path: string | (string | number)[], value: any) {
let path = _path;
// Optional string-path support.
// You can remove this `if` block if you don't need it.
if (typeof path === "string") {
const isQuoted = (str) => str[0] === '"' && str.at(-1) === '"';
path = path
.split(/[.\[\]]+/)
.filter((x) => x)
.map((x) => (!Number.isNaN(Number(x)) ? Number(x) : x))
.map((x) => (typeof x === "string" && isQuoted(x) ? x.slice(1, -1) : x));
}
if (path.length === 0) {
throw new Error("The path must have at least one entry in it");
}
const [head, ...tail] = path as any;
if (tail.length === 0) {
object[head] = value;
return object;
}
if (!(head in object)) {
object[head] = typeof tail[0] === "number" ? [] : {};
}
setPath(object[head], tail, value);
return object;
}
export function objectToJsLiteral(value: object, indent: number = 0, _level: number = 0): string {
const nl = indent ? "\n" : "";
const pad = (lvl: number) => (indent ? " ".repeat(indent * lvl) : "");
@@ -435,3 +505,50 @@ export function deepFreeze<T extends object>(object: T): T {
return Object.freeze(object);
}
export function convertNumberedObjectToArray(obj: object): any[] | object {
if (Object.keys(obj).every((key) => Number.isInteger(Number(key)))) {
return Object.values(obj);
}
return obj;
}
export function recursivelyReplacePlaceholders(
obj: any,
pattern: RegExp,
variables: Record<string, any>,
fallback?: any,
) {
if (typeof obj === "string") {
// check if the entire string matches the pattern
const match = obj.match(pattern);
if (match && match[0] === obj && match[1]) {
// full string match - replace with the actual value (preserving type)
const key = match[1];
const value = getPath(variables, key, null);
return value !== null ? value : fallback !== undefined ? fallback : obj;
}
// partial match - use string replacement
if (pattern.test(obj)) {
return obj.replace(pattern, (match, key) => {
const value = getPath(variables, key, null);
// convert to string for partial replacements
return value !== null
? String(value)
: fallback !== undefined
? String(fallback)
: match;
});
}
}
if (Array.isArray(obj)) {
return obj.map((item) => recursivelyReplacePlaceholders(item, pattern, variables, fallback));
}
if (obj && typeof obj === "object") {
return Object.entries(obj).reduce((acc, [key, value]) => {
acc[key] = recursivelyReplacePlaceholders(value, pattern, variables, fallback);
return acc;
}, {} as object);
}
return obj;
}

View File

@@ -61,3 +61,19 @@ export function invariant(condition: boolean | any, message: string) {
throw new Error(message);
}
}
export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
try {
fn();
return false;
} catch (e) {
if (instance) {
if (e instanceof instance) {
return true;
}
// if instance given but not what expected, throw
throw e;
}
return true;
}
}

View File

@@ -1,14 +1,58 @@
import { Exception } from "core/errors";
import { HttpStatus } from "bknd/utils";
import * as s from "jsonv-ts";
export { validator as jsc, type Options } from "jsonv-ts/hono";
export { describeRoute, schemaToSpec, openAPISpecs } from "jsonv-ts/hono";
export { describeRoute, schemaToSpec, openAPISpecs, info } from "jsonv-ts/hono";
export {
mcp,
McpServer,
Resource,
Tool,
mcpTool,
mcpResource,
getMcpServer,
stdioTransport,
McpClient,
logLevels as mcpLogLevels,
type McpClientConfig,
type ToolAnnotation,
type ToolHandlerCtx,
} from "jsonv-ts/mcp";
export { secret, SecretSchema } from "./secret";
export { s };
export const stripMark = <O extends object>(o: O): O => o;
export const mark = <O extends object>(o: O): O => o;
const symbol = Symbol("bknd-validation-mark");
export function stripMark<O = any>(obj: O) {
const newObj = structuredClone(obj);
mark(newObj, false);
return newObj as O;
}
export function mark(obj: any, validated = true) {
try {
if (typeof obj === "object" && obj !== null && !Array.isArray(obj)) {
if (validated) {
obj[symbol] = true;
} else {
delete obj[symbol];
}
for (const key in obj) {
if (typeof obj[key] === "object" && obj[key] !== null) {
mark(obj[key], validated);
}
}
}
} catch (e) {}
}
export function isMarked(obj: any) {
if (typeof obj !== "object" || obj === null) return false;
return obj[symbol] === true;
}
export const stringIdentifier = s.string({
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
@@ -16,7 +60,10 @@ export const stringIdentifier = s.string({
maxLength: 150,
});
export class InvalidSchemaError extends Error {
export class InvalidSchemaError extends Exception {
override name = "InvalidSchemaError";
override code = HttpStatus.UNPROCESSABLE_ENTITY;
constructor(
public schema: s.Schema,
public value: unknown,
@@ -24,7 +71,8 @@ export class InvalidSchemaError extends Error {
) {
super(
`Invalid schema given for ${JSON.stringify(value, null, 2)}\n\n` +
`Error: ${JSON.stringify(errors[0], null, 2)}`,
`Error: ${JSON.stringify(errors[0], null, 2)}\n\n` +
`Schema: ${JSON.stringify(schema.toJSON(), null, 2)}`,
);
}
@@ -59,6 +107,10 @@ export function parse<S extends s.Schema, Options extends ParseOptions = ParseOp
v: unknown,
opts?: Options,
): Options extends { coerce: true } ? s.StaticCoerced<S> : s.Static<S> {
if (!opts?.forceParse && !opts?.coerce && isMarked(v)) {
return v as any;
}
const schema = (opts?.clone ? cloneSchema(_schema as any) : _schema) as s.Schema;
let value =
opts?.coerce !== false

View File

@@ -1,6 +1,7 @@
import { StringSchema, type IStringOptions } from "jsonv-ts";
import type { s } from "bknd/utils";
import { StringSchema } from "jsonv-ts";
export class SecretSchema<O extends IStringOptions> extends StringSchema<O> {}
export class SecretSchema<O extends s.IStringOptions> extends StringSchema<O> {}
export const secret = <O extends IStringOptions>(o?: O): SecretSchema<O> & O =>
export const secret = <O extends s.IStringOptions>(o?: O): SecretSchema<O> & O =>
new SecretSchema(o) as any;

View File

@@ -6,6 +6,8 @@ const _oldConsoles = {
warn: console.warn,
error: console.error,
};
let _oldStderr: any;
let _oldStdout: any;
export async function withDisabledConsole<R>(
fn: () => Promise<R>,
@@ -36,10 +38,17 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
severities.forEach((severity) => {
console[severity] = () => null;
});
// Disable stderr
_oldStderr = process.stderr.write;
_oldStdout = process.stdout.write;
process.stderr.write = () => true;
process.stdout.write = () => true;
$console?.setLevel("critical");
}
export function enableConsoleLog() {
process.stderr.write = _oldStderr;
process.stdout.write = _oldStdout;
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});

View File

@@ -1,10 +1,16 @@
import { v4, v7 } from "uuid";
import { v4, v7, validate, version as uuidVersion } from "uuid";
// generates v4
export function uuid(): string {
return v4();
return v4();
}
// generates v7
export function uuidv7(): string {
return v7();
return v7();
}
// validate uuid
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
return validate(uuid) && uuidVersion(uuid) === version;
}

View File

@@ -1,5 +1,4 @@
import { transformObject } from "core/utils";
import { transformObject } from "bknd/utils";
import { Module } from "modules/Module";
import { DataController } from "./api/DataController";
import { type AppDataConfig, dataConfigSchema } from "./data-schema";
@@ -49,10 +48,9 @@ export class AppData extends Module<AppDataConfig> {
this.ctx.em.addIndex(index);
}
this.ctx.server.route(
this.basepath,
new DataController(this.ctx, this.config).getController(),
);
const dataController = new DataController(this.ctx, this.config);
dataController.registerMcp();
this.ctx.server.route(this.basepath, dataController.getController());
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
this.setBuilt();

View File

@@ -42,6 +42,9 @@ export class DataApi extends ModuleApi<DataApiOptions> {
) {
type Data = E extends keyof DB ? Selectable<DB[E]> : EntityData;
type T = RepositoryResultJSON<Data>;
// @todo: if none found, still returns meta...
return this.readMany(entity, {
...query,
limit: 1,

View File

@@ -1,12 +1,21 @@
import type { Handler } from "hono/types";
import type { ModuleBuildContext } from "modules";
import { Controller } from "modules/Controller";
import { jsc, s, describeRoute, schemaToSpec, omitKeys } from "bknd/utils";
import {
jsc,
s,
describeRoute,
schemaToSpec,
omitKeys,
pickKeys,
mcpTool,
convertNumberedObjectToArray,
} from "bknd/utils";
import * as SystemPermissions from "modules/permissions";
import type { AppDataConfig } from "../data-schema";
import type { EntityManager, EntityData } from "data/entities";
import * as DataPermissions from "data/permissions";
import { repoQuery, type RepoQuery } from "data/server/query";
import { EntityTypescript } from "data/entities/EntityTypescript";
export class DataController extends Controller {
constructor(
@@ -34,17 +43,9 @@ export class DataController extends Controller {
override getController() {
const { permission, auth } = this.middlewares;
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi));
const hono = this.create().use(auth(), permission(SystemPermissions.accessApi, {}));
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: sample implementation how to augment handler with additional info
function handler<HH extends Handler>(name: string, h: HH): any {
const func = h;
// @ts-ignore
func.description = name;
return func;
}
// info
hono.get(
"/",
@@ -52,16 +53,19 @@ export class DataController extends Controller {
summary: "Retrieve data configuration",
tags: ["data"],
}),
handler("data info", (c) => {
// sample implementation
return c.json(this.em.toJSON());
}),
(c) => c.json(this.em.toJSON()),
);
// sync endpoint
hono.get(
"/sync",
permission(DataPermissions.databaseSync),
permission(DataPermissions.databaseSync, {}),
mcpTool("data_sync", {
// @todo: should be removed if readonly
annotations: {
destructiveHint: true,
},
}),
describeRoute({
summary: "Sync database schema",
tags: ["data"],
@@ -77,9 +81,7 @@ export class DataController extends Controller {
),
async (c) => {
const { force, drop } = c.req.valid("query");
//console.log("force", force);
const tables = await this.em.schema().introspect();
//console.log("tables", tables);
const changes = await this.em.schema().sync({
force,
drop,
@@ -94,7 +96,9 @@ export class DataController extends Controller {
// read entity schema
hono.get(
"/schema.json",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Retrieve data schema",
tags: ["data"],
@@ -120,7 +124,9 @@ export class DataController extends Controller {
// read schema
hono.get(
"/schemas/:entity/:context?",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Retrieve entity schema",
tags: ["data"],
@@ -152,6 +158,22 @@ export class DataController extends Controller {
},
);
hono.get(
"/types",
permission(SystemPermissions.schemaRead, {
context: (c) => ({ module: "data" }),
}),
describeRoute({
summary: "Retrieve data typescript definitions",
tags: ["data"],
}),
mcpTool("data_types"),
async (c) => {
const et = new EntityTypescript(this.em);
return c.text(et.toString());
},
);
// entity endpoints
hono.route("/entity", this.getEntityRoutes());
@@ -160,11 +182,14 @@ export class DataController extends Controller {
*/
hono.get(
"/info/:entity",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Retrieve entity info",
tags: ["data"],
}),
mcpTool("data_entity_info"),
jsc("param", s.object({ entity: entitiesEnum })),
async (c) => {
const { entity } = c.req.param();
@@ -201,7 +226,9 @@ export class DataController extends Controller {
const entitiesEnum = this.getEntitiesEnum(this.em);
// @todo: make dynamic based on entity
const idType = s.anyOf([s.number(), s.string()], { coerce: (v) => v as number | string });
const idType = s.anyOf([s.number({ title: "Integer" }), s.string({ title: "UUID" })], {
coerce: (v) => v as number | string,
});
/**
* Function endpoints
@@ -209,11 +236,14 @@ export class DataController extends Controller {
// fn: count
hono.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Count entities",
tags: ["data"],
}),
mcpTool("data_entity_fn_count"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
@@ -231,11 +261,14 @@ export class DataController extends Controller {
// fn: exists
hono.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
describeRoute({
summary: "Check if entity exists",
tags: ["data"],
}),
mcpTool("data_entity_fn_exists"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
@@ -268,6 +301,9 @@ export class DataController extends Controller {
(p) => pick.includes(p.name),
) as any),
];
const saveRepoQuerySchema = (pick: string[] = Object.keys(saveRepoQuery.properties)) => {
return s.object(pickKeys(saveRepoQuery.properties, pick as any));
};
hono.get(
"/:entity",
@@ -276,16 +312,26 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["limit", "offset", "sort", "select", "join"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("query", repoQuery, { skipOpenAPI: true }),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity,
});
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
const result = await this.em.repository(entity).findMany({
...options,
where: merge(options.where),
});
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -299,7 +345,16 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(["offset", "sort", "select"]),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_read_one", {
inputSchema: {
param: s.object({ entity: entitiesEnum, id: idType }),
query: saveRepoQuerySchema(["offset", "sort", "select"]),
},
noErrorCodes: [404],
}),
jsc(
"param",
s.object({
@@ -310,11 +365,19 @@ export class DataController extends Controller {
jsc("query", repoQuery, { skipOpenAPI: true }),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
if (!this.entityExists(entity) || !id) {
return this.notFound(c);
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em.repository(entity).findId(id, options);
const { merge } = this.ctx.guard.filters(
DataPermissions.entityRead,
c,
c.req.valid("param"),
);
const id_name = this.em.entity(entity).getPrimaryField().name;
const result = await this.em
.repository(entity)
.findOne(merge({ [id_name]: id }), options);
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -328,7 +391,9 @@ export class DataController extends Controller {
parameters: saveRepoQueryParams(),
tags: ["data"],
}),
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ ...c.req.param() }) as any,
}),
jsc(
"param",
s.object({
@@ -345,9 +410,20 @@ export class DataController extends Controller {
}
const options = c.req.valid("query") as RepoQuery;
const result = await this.em
const { entity: newEntity } = this.em
.repository(entity)
.findManyByReference(id, reference, options);
.getEntityByReference(reference);
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity: newEntity.name,
id,
reference,
});
const result = await this.em.repository(entity).findManyByReference(id, reference, {
...options,
where: merge(options.where),
});
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -374,7 +450,15 @@ export class DataController extends Controller {
},
tags: ["data"],
}),
permission(DataPermissions.entityRead),
permission(DataPermissions.entityRead, {
context: (c) => ({ entity: c.req.param("entity") }),
}),
mcpTool("data_entity_read_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: fnQuery,
},
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery, { skipOpenAPI: true }),
async (c) => {
@@ -383,7 +467,13 @@ export class DataController extends Controller {
return this.notFound(c);
}
const options = c.req.valid("json") as RepoQuery;
const result = await this.em.repository(entity).findMany(options);
const { merge } = this.ctx.guard.filters(DataPermissions.entityRead, c, {
entity,
});
const result = await this.em.repository(entity).findMany({
...options,
where: merge(options.where),
});
return c.json(result, { status: result.data ? 200 : 404 });
},
@@ -399,7 +489,10 @@ export class DataController extends Controller {
summary: "Insert one or many",
tags: ["data"],
}),
permission(DataPermissions.entityCreate),
permission(DataPermissions.entityCreate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_insert"),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", s.anyOf([s.object({}), s.array(s.object({}))])),
async (c) => {
@@ -407,7 +500,19 @@ export class DataController extends Controller {
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData | EntityData[];
const _body = (await c.req.json()) as EntityData | EntityData[];
// @todo: check on jsonv-ts how to handle this better
// temporary fix for numbered object to array
// this happens when the MCP tool uses the allOf function
// to transform all validation targets into a single object
const body = convertNumberedObjectToArray(_body);
this.ctx.guard
.filters(DataPermissions.entityCreate, c, {
entity,
})
.matches(body, { throwOnError: true });
if (Array.isArray(body)) {
const result = await this.em.mutator(entity).insertMany(body);
@@ -426,7 +531,18 @@ export class DataController extends Controller {
summary: "Update many",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
permission(DataPermissions.entityUpdate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_update_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: s.object({
update: s.object({}),
where: s.object({}),
}),
},
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc(
"json",
@@ -444,7 +560,10 @@ export class DataController extends Controller {
update: EntityData;
where: RepoQuery["where"];
};
const result = await this.em.mutator(entity).updateWhere(update, where);
const { merge } = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
entity,
});
const result = await this.em.mutator(entity).updateWhere(update, merge(where));
return c.json(result);
},
@@ -457,7 +576,10 @@ export class DataController extends Controller {
summary: "Update one",
tags: ["data"],
}),
permission(DataPermissions.entityUpdate),
permission(DataPermissions.entityUpdate, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_update_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
jsc("json", s.object({})),
async (c) => {
@@ -466,6 +588,17 @@ export class DataController extends Controller {
return this.notFound(c);
}
const body = (await c.req.json()) as EntityData;
const fns = this.ctx.guard.filters(DataPermissions.entityUpdate, c, {
entity,
id,
});
// if it has filters attached, fetch entry and make the check
if (fns.filters.length > 0) {
const { data } = await this.em.repository(entity).findId(id);
fns.matches(data, { throwOnError: true });
}
const result = await this.em.mutator(entity).updateOne(id, body);
return c.json(result);
@@ -479,13 +612,28 @@ export class DataController extends Controller {
summary: "Delete one",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
permission(DataPermissions.entityDelete, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_delete_one"),
jsc("param", s.object({ entity: entitiesEnum, id: idType })),
async (c) => {
const { entity, id } = c.req.valid("param");
if (!this.entityExists(entity)) {
return this.notFound(c);
}
const fns = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
entity,
id,
});
// if it has filters attached, fetch entry and make the check
if (fns.filters.length > 0) {
const { data } = await this.em.repository(entity).findId(id);
fns.matches(data, { throwOnError: true });
}
const result = await this.em.mutator(entity).deleteOne(id);
return c.json(result);
@@ -499,7 +647,15 @@ export class DataController extends Controller {
summary: "Delete many",
tags: ["data"],
}),
permission(DataPermissions.entityDelete),
permission(DataPermissions.entityDelete, {
context: (c) => ({ ...c.req.param() }) as any,
}),
mcpTool("data_entity_delete_many", {
inputSchema: {
param: s.object({ entity: entitiesEnum }),
json: s.object({}),
},
}),
jsc("param", s.object({ entity: entitiesEnum })),
jsc("json", repoQuery.properties.where),
async (c) => {
@@ -508,7 +664,10 @@ export class DataController extends Controller {
return this.notFound(c);
}
const where = (await c.req.json()) as RepoQuery["where"];
const result = await this.em.mutator(entity).deleteWhere(where);
const { merge } = this.ctx.guard.filters(DataPermissions.entityDelete, c, {
entity,
});
const result = await this.em.mutator(entity).deleteWhere(merge(where));
return c.json(result);
},
@@ -516,4 +675,35 @@ export class DataController extends Controller {
return hono;
}
override registerMcp() {
this.ctx.mcp
.resource(
"data_entities",
"bknd://data/entities",
(c) => c.json(c.context.ctx().em.toJSON().entities),
{
title: "Entities",
description: "Retrieve all entities",
},
)
.resource(
"data_relations",
"bknd://data/relations",
(c) => c.json(c.context.ctx().em.toJSON().relations),
{
title: "Relations",
description: "Retrieve all relations",
},
)
.resource(
"data_indices",
"bknd://data/indices",
(c) => c.json(c.context.ctx().em.toJSON().indices),
{
title: "Indices",
description: "Retrieve all indices",
},
);
}
}

View File

@@ -230,3 +230,15 @@ export function customIntrospector<T extends Constructor<Dialect>>(
},
};
}
export class DummyConnection extends Connection {
override name = "dummy";
constructor() {
super(undefined as any);
}
override getFieldSchema(): SchemaResponse {
throw new Error("Method not implemented.");
}
}

View File

@@ -1,9 +1,10 @@
import type { TestRunner } from "core/test";
import { Connection, type FieldSpec } from "./Connection";
import { getPath } from "core/utils";
import { getPath } from "bknd/utils";
import * as proto from "data/prototype";
import { createApp } from "App";
import type { MaybePromise } from "core/types";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
// @todo: add various datatypes: string, number, boolean, object, array, null, undefined, date, etc.
// @todo: add toDriver/fromDriver tests on all types and fields
@@ -21,7 +22,9 @@ export function connectionTestSuite(
rawDialectDetails: string[];
},
) {
const { test, expect, describe, beforeEach, afterEach, afterAll } = testRunner;
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
beforeAll(() => disableConsoleLog());
afterAll(() => enableConsoleLog());
describe("base", () => {
let ctx: Awaited<ReturnType<typeof makeConnection>>;
@@ -247,7 +250,7 @@ export function connectionTestSuite(
const app = createApp({
connection: ctx.connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});
@@ -333,7 +336,7 @@ export function connectionTestSuite(
const app = createApp({
connection: ctx.connection,
initialConfig: {
config: {
data: schema.toJSON(),
},
});

View File

@@ -9,6 +9,7 @@ export {
type ConnQueryResults,
customIntrospector,
} from "./Connection";
export { DummyConnection } from "./DummyConnection";
// sqlite
export { SqliteConnection } from "./sqlite/SqliteConnection";

Some files were not shown because too many files have changed in this diff Show More