Merge remote-tracking branch 'origin/release/0.11' into feat/init-e2e

# Conflicts:
#	app/.gitignore
#	app/package.json
#	app/vite.dev.ts
#	bun.lock
This commit is contained in:
dswbx
2025-04-02 20:24:18 +02:00
86 changed files with 1768 additions and 815 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
playwright-report
test-results
test-results
bknd.config.*
+62
View File
@@ -0,0 +1,62 @@
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
import * as adapter from "adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("adapter", () => {
it("makes config", () => {
expect(adapter.makeConfig({})).toEqual({});
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
// merges everything returned from `app` with the config
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({
env: { TEST: "test" },
} as any);
});
it("reuses apps correctly", async () => {
const id = crypto.randomUUID();
const first = await adapter.createAdapterApp(
{
initialConfig: { server: { cors: { origin: "random" } } },
},
undefined,
{ id },
);
const second = await adapter.createAdapterApp();
const third = await adapter.createAdapterApp(undefined, undefined, { id });
await first.build();
await second.build();
await third.build();
expect(first.toJSON().server.cors.origin).toEqual("random");
expect(first).toBe(third);
expect(first).not.toBe(second);
expect(second).not.toBe(third);
expect(second.toJSON().server.cors.origin).toEqual("*");
// recreate the first one
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
await first2.build();
expect(first2).not.toBe(first);
expect(first2).not.toBe(third);
expect(first2).not.toBe(second);
expect(first2.toJSON().server.cors.origin).toEqual("*");
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createFrameworkApp,
label: "framework app",
});
adapterTestSuite(bunTestRunner, {
makeApp: adapter.createRuntimeApp,
label: "runtime app",
});
});
+11 -1
View File
@@ -5,7 +5,8 @@ import { DataApi } from "../../src/data/api/DataApi";
import { DataController } from "../../src/data/api/DataController";
import { dataConfigSchema } from "../../src/data/data-schema";
import * as proto from "../../src/data/prototype";
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper";
import { schemaToEm } from "../helper";
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
@@ -64,6 +65,15 @@ describe("DataApi", () => {
const res = await req;
expect(res.data).toEqual(payload as any);
}
{
// make sure sort is working
const req = await api.readMany("posts", {
select: ["title"],
sort: "-id",
});
expect(req.data).toEqual(payload.reverse() as any);
}
});
it("updates many", async () => {
+84 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, mock, test } from "bun:test";
import type { ModuleBuildContext } from "../../src";
import { type App, createApp } from "../../src/App";
import { App, createApp } from "../../src/App";
import * as proto from "../../src/data/prototype";
describe("App", () => {
@@ -51,4 +51,87 @@ describe("App", () => {
expect(todos[0]?.title).toBe("ctx");
expect(todos[1]?.title).toBe("api");
});
test("lifecycle events are triggered", async () => {
const firstBoot = mock(() => null);
const configUpdate = mock(() => null);
const appBuilt = mock(() => null);
const appRequest = mock(() => null);
const beforeResponse = mock(() => null);
const app = createApp();
app.emgr.onEvent(
App.Events.AppFirstBoot,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppFirstBoot);
expect(event.params.app.version()).toBe(app.version());
firstBoot();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBuiltEvent,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBuiltEvent);
expect(event.params.app.version()).toBe(app.version());
appBuilt();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppConfigUpdatedEvent,
() => {
configUpdate();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppRequest,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppRequest);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.request).toBeInstanceOf(Request);
appRequest();
},
"sync",
);
app.emgr.onEvent(
App.Events.AppBeforeResponse,
(event) => {
expect(event).toBeInstanceOf(App.Events.AppBeforeResponse);
expect(event.params.app.version()).toBe(app.version());
expect(event.params.response).toBeInstanceOf(Response);
beforeResponse();
},
"sync",
);
await app.build();
expect(firstBoot).toHaveBeenCalled();
expect(appBuilt).toHaveBeenCalled();
//expect(configUpdate).toHaveBeenCalled();
expect(appRequest).not.toHaveBeenCalled();
expect(beforeResponse).not.toHaveBeenCalled();
});
test("emgr exec modes", async () => {
const called = mock(() => null);
const app = createApp({
options: {
asyncEventsMode: "sync",
},
});
// register async listener
app.emgr.onEvent(App.Events.AppFirstBoot, async () => {
called();
});
await app.build();
await app.server.request(new Request("http://localhost"));
// expect async listeners to be executed sync after request
expect(called).toHaveBeenCalled();
});
});
+8 -6
View File
@@ -70,6 +70,9 @@ describe("EventManager", async () => {
new SpecialEvent({ foo: "bar" });
new InformationalEvent();
// execute asyncs
await emgr.executeAsyncs();
expect(call).toHaveBeenCalledTimes(2);
expect(delayed).toHaveBeenCalled();
});
@@ -80,15 +83,11 @@ describe("EventManager", async () => {
call();
return Promise.all(p);
};
const emgr = new EventManager(
{ InformationalEvent },
{
asyncExecutor,
},
);
const emgr = new EventManager({ InformationalEvent });
emgr.onEvent(InformationalEvent, async () => {});
await emgr.emit(new InformationalEvent());
await emgr.executeAsyncs(asyncExecutor);
expect(call).toHaveBeenCalled();
});
@@ -125,6 +124,9 @@ describe("EventManager", async () => {
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
expect(e2.returned).toBe(true);
expect(e2.params.foo).toBe("bar-1-0");
await emgr.executeAsyncs();
expect(onInvalidReturn).toHaveBeenCalled();
expect(asyncEventCallback).toHaveBeenCalled();
});
+3
View File
@@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => {
test("events were fired", async () => {
const { data } = await mutator.insertOne({ label: "test" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
await mutator.updateOne(data.id, { label: "test2" });
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
await mutator.deleteOne(data.id);
await mutator.emgr.executeAsyncs();
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
});
+55 -6
View File
@@ -1,6 +1,6 @@
import { afterAll, describe, expect, test } from "bun:test";
import type { Kysely, Transaction } from "kysely";
import { Perf } from "../../../src/core/utils";
import { Perf } from "core/utils";
import {
Entity,
EntityManager,
@@ -8,7 +8,10 @@ import {
ManyToOneRelation,
RepositoryEvents,
TextField,
} from "../../../src/data";
entity as $entity,
text as $text,
em as $em,
} from "data";
import { getDummyConnection } from "../helper";
type E = Kysely<any> | Transaction<any>;
@@ -177,6 +180,47 @@ describe("[Repository]", async () => {
const res5 = await em.repository(items).exists({});
expect(res5.exists).toBe(true);
});
test("option: silent", async () => {
const em = $em({
items: $entity("items", {
label: $text(),
}),
}).proto.withConnection(getDummyConnection().dummyConnection);
// should throw because table doesn't exist
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
// should silently return empty result
expect(
em
.repo("items", { silent: true })
.findMany({})
.then((r) => r.data),
).resolves.toEqual([]);
});
test("option: includeCounts", async () => {
const em = $em({
items: $entity("items", {
label: $text(),
}),
}).proto.withConnection(getDummyConnection().dummyConnection);
await em.schema().sync({ force: true });
expect(
em
.repo("items")
.findMany({})
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([0, 0]);
expect(
em
.repo("items", { includeCounts: false })
.findMany({})
.then((r) => [r.meta.count, r.meta.total]),
).resolves.toEqual([undefined, undefined]);
});
});
describe("[data] Repository (Events)", async () => {
@@ -198,22 +242,27 @@ describe("[data] Repository (Events)", async () => {
});
test("events were fired", async () => {
await em.repository(items).findId(1);
const repo = em.repository(items);
await repo.findId(1);
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findOne({ id: 1 });
await repo.findOne({ id: 1 });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findMany({ where: { id: 1 } });
await repo.findMany({ where: { id: 1 } });
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
await em.repository(items).findManyByReference(1, "categories");
await repo.findManyByReference(1, "categories");
await repo.emgr.executeAsyncs();
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
events.clear();
+1
View File
@@ -78,6 +78,7 @@ export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
export async function enableFetchLogging() {
const originalFetch = global.fetch;
// @ts-ignore
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await originalFetch(input, init);
const url = input instanceof URL || typeof input === "string" ? input : input.url;
+5 -3
View File
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage";
import { type FileBody, Storage } from "../../src/media/storage/Storage";
import * as StorageEvents from "../../src/media/storage/events";
import { StorageAdapter } from "media";
class TestAdapter implements StorageAdapter {
class TestAdapter extends StorageAdapter {
files: Record<string, FileBody> = {};
getName() {
@@ -61,7 +62,7 @@ describe("Storage", async () => {
test("uploads a file", async () => {
const {
meta: { type, size },
} = await storage.uploadFile("hello", "world.txt");
} = await storage.uploadFile("hello" as any, "world.txt");
expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
});
@@ -71,6 +72,7 @@ describe("Storage", async () => {
});
test("events were fired", async () => {
await storage.emgr.executeAsyncs();
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
// @todo: file access must be tested in controllers
+5
View File
@@ -96,5 +96,10 @@ describe("media/mime-types", () => {
`getRandomizedFilename(): ${filename} should end with ${ext}`,
).toBe(ext);
}
// make sure it keeps the extension, even if the file has a different type
const file = new File([""], "image.jpg", { type: "text/plain" });
const [, ext] = getRandomizedFilename(file).split(".");
expect(ext).toBe("jpg");
});
});
+22 -21
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.10.3-rc.1",
"version": "0.11.0-rc.2",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -15,10 +15,11 @@
},
"scripts": {
"dev": "vite",
"test": "bun run test:unit && bun run test:e2e",
"test": "ALL_TESTS=1 bun test --bail",
"test:all": "bun run test && bun run test:node",
"test:bun": "ALL_TESTS=1 bun test --bail",
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
"build": "NODE_ENV=production bun run build.ts --minify --types",
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
@@ -31,7 +32,7 @@
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run build:all && cp ../README.md ./",
"postpublish": "rm -f README.md",
"test:unit": "vitest",
"test:unit:coverage": "vitest run --coverage",
@@ -47,7 +48,7 @@
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.2",
"@hello-pangea/dnd": "^18.0.1",
"@libsql/client": "^0.14.0",
"@libsql/client": "^0.15.2",
"@mantine/core": "^7.17.1",
"@mantine/hooks": "^7.17.1",
"@sinclair/typebox": "^0.34.30",
@@ -136,52 +137,52 @@
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
"require": "./dist/index.js"
},
"./ui": {
"types": "./dist/types/ui/index.d.ts",
"import": "./dist/ui/index.js",
"require": "./dist/ui/index.cjs"
"require": "./dist/ui/index.js"
},
"./elements": {
"types": "./dist/types/ui/elements/index.d.ts",
"import": "./dist/ui/elements/index.js",
"require": "./dist/ui/elements/index.cjs"
"require": "./dist/ui/elements/index.js"
},
"./client": {
"types": "./dist/types/ui/client/index.d.ts",
"import": "./dist/ui/client/index.js",
"require": "./dist/ui/client/index.cjs"
"require": "./dist/ui/client/index.js"
},
"./data": {
"types": "./dist/types/data/index.d.ts",
"import": "./dist/data/index.js",
"require": "./dist/data/index.cjs"
"require": "./dist/data/index.js"
},
"./core": {
"types": "./dist/types/core/index.d.ts",
"import": "./dist/core/index.js",
"require": "./dist/core/index.cjs"
"require": "./dist/core/index.js"
},
"./utils": {
"types": "./dist/types/core/utils/index.d.ts",
"import": "./dist/core/utils/index.js",
"require": "./dist/core/utils/index.cjs"
"require": "./dist/core/utils/index.js"
},
"./cli": {
"types": "./dist/types/cli/index.d.ts",
"import": "./dist/cli/index.js",
"require": "./dist/cli/index.cjs"
"require": "./dist/cli/index.js"
},
"./media": {
"types": "./dist/types/media/index.d.ts",
"import": "./dist/media/index.js",
"require": "./dist/media/index.cjs"
"require": "./dist/media/index.js"
},
"./adapter/cloudflare": {
"types": "./dist/types/adapter/cloudflare/index.d.ts",
"import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs"
"require": "./dist/adapter/cloudflare/index.js"
},
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
@@ -190,37 +191,37 @@
"./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js",
"require": "./dist/adapter/vite/index.cjs"
"require": "./dist/adapter/vite/index.js"
},
"./adapter/nextjs": {
"types": "./dist/types/adapter/nextjs/index.d.ts",
"import": "./dist/adapter/nextjs/index.js",
"require": "./dist/adapter/nextjs/index.cjs"
"require": "./dist/adapter/nextjs/index.js"
},
"./adapter/react-router": {
"types": "./dist/types/adapter/react-router/index.d.ts",
"import": "./dist/adapter/react-router/index.js",
"require": "./dist/adapter/react-router/index.cjs"
"require": "./dist/adapter/react-router/index.js"
},
"./adapter/bun": {
"types": "./dist/types/adapter/bun/index.d.ts",
"import": "./dist/adapter/bun/index.js",
"require": "./dist/adapter/bun/index.cjs"
"require": "./dist/adapter/bun/index.js"
},
"./adapter/node": {
"types": "./dist/types/adapter/node/index.d.ts",
"import": "./dist/adapter/node/index.js",
"require": "./dist/adapter/node/index.cjs"
"require": "./dist/adapter/node/index.js"
},
"./adapter/astro": {
"types": "./dist/types/adapter/astro/index.d.ts",
"import": "./dist/adapter/astro/index.js",
"require": "./dist/adapter/astro/index.cjs"
"require": "./dist/adapter/astro/index.js"
},
"./adapter/aws": {
"types": "./dist/types/adapter/aws/index.d.ts",
"import": "./dist/adapter/aws/index.js",
"require": "./dist/adapter/aws/index.cjs"
"require": "./dist/adapter/aws/index.js"
},
"./dist/main.css": "./dist/ui/main.css",
"./dist/styles.css": "./dist/ui/styles.css",
+71 -33
View File
@@ -4,9 +4,10 @@ import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import {
ModuleManager,
type InitialModuleConfigs,
type ModuleBuildContext,
ModuleManager,
type ModuleConfigs,
type ModuleManagerOptions,
type Modules,
} from "modules/ModuleManager";
@@ -16,6 +17,7 @@ import { SystemController } from "modules/server/SystemController";
// biome-ignore format: must be there
import { Api, type ApiOptions } from "Api";
import type { ServerEnv } from "modules/Controller";
export type AppPlugin = (app: App) => Promise<void> | void;
@@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent {
export class AppFirstBoot extends AppEvent {
static override slug = "app-first-boot";
}
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export class AppRequest extends AppEvent<{ request: Request }> {
static override slug = "app-request";
}
export class AppBeforeResponse extends AppEvent<{ request: Request; response: Response }> {
static override slug = "app-before-response";
}
export const AppEvents = {
AppConfigUpdatedEvent,
AppBuiltEvent,
AppFirstBoot,
AppRequest,
AppBeforeResponse,
} as const;
export type AppOptions = {
plugins?: AppPlugin[];
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
asyncEventsMode?: "sync" | "async" | "none";
};
export type CreateAppConfig = {
connection?:
@@ -53,12 +68,14 @@ export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App {
modules: ModuleManager;
static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
private trigger_first_boot = false;
private plugins: AppPlugin[];
private _id: string = crypto.randomUUID();
private _building: boolean = false;
constructor(
@@ -70,35 +87,9 @@ export class App {
this.modules = new ModuleManager(connection, {
...(options?.manager ?? {}),
initial: _initialConfig,
onUpdated: async (key, config) => {
// if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", key);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
},
onFirstBoot: async () => {
$console.log("App first boot");
this.trigger_first_boot = true;
},
onServerInit: async (server) => {
server.use(async (c, next) => {
c.set("app", this);
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
});
},
onUpdated: this.onUpdated.bind(this),
onFirstBoot: this.onFirstBoot.bind(this),
onServerInit: this.onServerInit.bind(this),
});
this.modules.ctx().emgr.registerEvents(AppEvents);
}
@@ -213,6 +204,53 @@ export class App {
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
}
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".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) {
$console.warn("App config updated, but event manager is disabled, skip.");
return;
}
$console.log("App config updated", module);
// @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}
async onFirstBoot() {
$console.log("App first boot");
this.trigger_first_boot = true;
}
async onServerInit(server: Hono<ServerEnv>) {
server.use(async (c, next) => {
c.set("app", this);
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
await next();
try {
// gracefully add the app id
c.res.headers.set("X-bknd-id", this._id);
} catch (e) {}
await this.emgr.emit(
new AppBeforeResponse({ app: this, request: c.req.raw, response: c.res }),
);
// execute collected async events (async by default)
switch (this.options?.asyncEventsMode ?? "async") {
case "sync":
await this.emgr.executeAsyncs();
break;
case "async":
this.emgr.executeAsyncs();
break;
}
});
}
}
export function createApp(config: CreateAppConfig = {}) {
+90
View File
@@ -0,0 +1,90 @@
import type { TestRunner } from "core/test";
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
import type { App } from "App";
export function adapterTestSuite<
Config extends BkndConfig = BkndConfig,
Args extends DefaultArgs = DefaultArgs,
>(
testRunner: TestRunner,
{
makeApp,
makeHandler,
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>;
label?: string;
overrides?: {
dbUrl?: string;
};
},
) {
const { test, expect, mock } = testRunner;
const id = crypto.randomUUID();
test(`creates ${label}`, async () => {
const beforeBuild = mock(async () => null) as any;
const onBuilt = mock(async () => null) as any;
const config = {
app: (env) => ({
connection: { url: env.url },
initialConfig: {
server: { cors: { origin: env.origin } },
},
}),
beforeBuild,
onBuilt,
} as const satisfies BkndConfig;
const app = await makeApp(
config as any,
{
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(onBuilt).toHaveBeenCalledTimes(1);
});
if (makeHandler) {
const getConfig = async (fetcher: (r: Request) => Promise<Response>) => {
const res = await fetcher(new Request("http://localhost:3000/api/system/config"));
const data = (await res.json()) as any;
return { res, data };
};
test("responds with the same app id", async () => {
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("localhost");
});
test("creates fresh & responds to api config", async () => {
// set the same id, but force recreate
const fetcher = makeHandler(undefined, undefined, { id, force: true });
const { res, data } = await getConfig(fetcher);
expect(res.ok).toBe(true);
expect(res.status).toBe(200);
expect(data.server.cors.origin).toEqual("*");
});
}
}
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as astro from "./astro.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("astro adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: astro.getApp,
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
});
});
+16 -25
View File
@@ -1,34 +1,25 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
type AstroEnv = NodeJS.ProcessEnv;
type TAstro = {
request: Request;
};
export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
export type Options = {
mode?: "static" | "dynamic";
} & Omit<ApiOptions, "host"> & {
host?: string;
};
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
const api = new Api({
host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined,
});
await api.verifyAuth();
return api;
export async function getApp<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts: FrameworkOptions = {},
) {
return await createFrameworkApp(config, args ?? import.meta.env, opts);
}
let app: App;
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: Context) => {
if (!app) {
app = await createFrameworkApp(config, args);
}
return app.fetch(args.request);
export function serve<Env = AstroEnv>(
config: AstroBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (fnArgs: TAstro) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
};
}
+62 -54
View File
@@ -1,68 +1,76 @@
import type { App } from "bknd";
import { handle } from "hono/aws-lambda";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { serveStatic } from "@hono/node-server/serve-static";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
export type AwsLambdaBkndConfig = RuntimeBkndConfig & {
assets?:
| {
mode: "local";
root: string;
}
| {
mode: "url";
url: string;
};
};
type AwsLambdaEnv = object;
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
RuntimeBkndConfig<Env> & {
assets?:
| {
mode: "local";
root: string;
}
| {
mode: "url";
url: string;
};
};
let app: App;
export async function createApp({
adminOptions = false,
assets,
...config
}: AwsLambdaBkndConfig = {}) {
if (!app) {
let additional: Partial<RuntimeBkndConfig> = {
adminOptions,
};
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,
};
if (assets?.mode) {
switch (assets.mode) {
case "local":
// @todo: serve static outside app context
additional = {
adminOptions: adminOptions === false ? undefined : adminOptions,
serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({
root: assets.root,
onFound: (path, c) => {
c.res.headers.set("Cache-Control", "public, max-age=31536000");
},
}),
};
break;
case "url":
additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}),
assets_path: assets.url,
};
break;
default:
throw new Error("Invalid assets mode");
}
if (assets?.mode) {
switch (assets.mode) {
case "local":
// @todo: serve static outside app context
additional = {
adminOptions: adminOptions === false ? undefined : adminOptions,
serveStatic: serveStatic({
root: assets.root,
onFound: (path, c) => {
c.res.headers.set("Cache-Control", "public, max-age=31536000");
},
}),
};
break;
case "url":
additional.adminOptions = {
...(typeof adminOptions === "object" ? adminOptions : {}),
assets_path: assets.url,
};
break;
default:
throw new Error("Invalid assets mode");
}
app = await createRuntimeApp({
...config,
...additional,
});
}
return app;
return await createRuntimeApp(
{
...config,
...additional,
},
args ?? process.env,
opts,
);
}
export function serveLambda(config: AwsLambdaBkndConfig = {}) {
console.log("serving lambda");
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
config: AwsLambdaBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (event) => {
const app = await createApp(config);
const app = await createApp(config, args, opts);
return await handle(app.server)(event);
};
}
// compatibility with old code
export const serveLambda = serve;
+19
View File
@@ -0,0 +1,19 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as awsLambda from "./aws-lambda.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
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);
return app.fetch(request);
},
});
});
+15
View File
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as bun from "./bun.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("bun adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: bun.createApp,
makeHandler: bun.createHandler,
});
});
+47 -29
View File
@@ -1,47 +1,64 @@
/// <reference types="bun-types" />
import path from "node:path";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun";
import { serveStatic } from "hono/bun";
let app: App;
type BunEnv = Bun.Env;
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
export async function createApp<Env = BunEnv>(
{ distPath, ...config }: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
registerLocalMediaAdapter();
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
return await createRuntimeApp(
{
...config,
serveStatic: serveStatic({ root }),
});
}
return app;
},
args ?? (process.env as Env),
opts,
);
}
export function serve({
distPath,
connection,
initialConfig,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
...serveOptions
}: BunBkndConfig = {}) {
export function createHandler<Env = BunEnv>(
config: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts);
return app.fetch(req);
};
}
export function serve<Env = BunEnv>(
{
distPath,
connection,
initialConfig,
options,
port = config.server.default_port,
onBuilt,
buildConfig,
adminOptions,
...serveOptions
}: BunBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
Bun.serve({
...serveOptions,
port,
fetch: async (request: Request) => {
const app = await createApp({
fetch: createHandler(
{
connection,
initialConfig,
options,
@@ -49,9 +66,10 @@ export function serve({
buildConfig,
adminOptions,
distPath,
});
return app.fetch(request);
},
},
args,
opts,
),
});
console.log(`Server is running on http://localhost:${port}`);
+7
View File
@@ -0,0 +1,7 @@
import { expect, test, mock } from "bun:test";
export const bunTestRunner = {
expect,
test,
mock,
};
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { makeApp } from "./modes/fresh";
import { makeConfig } 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";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("cf adapter", () => {
const DB_URL = ":memory:";
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
request: request ?? (null as any),
env: env ?? { DB_URL },
ctx: ctx ?? (null as any),
});
it("makes config", async () => {
expect(
makeConfig(
{
connection: { url: DB_URL },
},
{},
),
).toEqual({ connection: { url: DB_URL } });
expect(
makeConfig(
{
app: (env) => ({
connection: { url: env.DB_URL },
}),
},
{
DB_URL,
},
),
).toEqual({ connection: { url: DB_URL } });
});
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
makeApp,
makeHandler: (c, a, o) => {
return async (request: any) => {
const app = await makeApp(
// needs a fallback, otherwise tries to launch D1
c ?? {
connection: { url: DB_URL },
},
a,
o,
);
return app.fetch(request);
};
},
});
});
@@ -1,18 +1,16 @@
/// <reference types="@cloudflare/workers-types" />
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
import type { FrameworkBkndConfig } from "bknd/adapter";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import { D1Connection } from "./D1Connection";
import { registerMedia } from "./StorageR2Adapter";
import { getBinding } from "./bindings";
import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh";
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
export type CloudflareEnv = object;
export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env> & {
mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (args: Context<Env>) => {
bindings?: (args: Env) => {
kv?: KVNamespace;
dobj?: DurableObjectNamespace;
db?: D1Database;
@@ -26,45 +24,15 @@ export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>>
html?: string;
};
export type Context<Env = any> = {
export type Context<Env = CloudflareEnv> = {
request: Request;
env: Env;
ctx: ExecutionContext;
};
let media_registered: boolean = false;
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
if (!media_registered) {
registerMedia(context.env as any);
media_registered = true;
}
const appConfig = makeConfig(config, context);
const bindings = config.bindings?.(context);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(context.env ?? {}).length > 0) {
const binding = getBinding(context.env, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
return appConfig;
}
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env> = {},
) {
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
@@ -99,7 +67,7 @@ export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
}
}
const context = { request, env, ctx } as Context;
const context = { request, env, ctx } as Context<Env>;
const mode = config.mode ?? "warm";
switch (mode) {
+64
View File
@@ -0,0 +1,64 @@
import { registerMedia } from "./storage/StorageR2Adapter";
import { getBinding } from "./bindings";
import { D1Connection } from "./D1Connection";
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
import { App } from "bknd";
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
import type { ExecutionContext } from "hono";
export const constants = {
exec_async_event_id: "cf_register_waituntil",
cache_endpoint: "/__bknd/cache",
do_endpoint: "/__bknd/do",
};
let media_registered: boolean = false;
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
) {
if (!media_registered) {
registerMedia(args as any);
media_registered = true;
}
const appConfig = makeAdapterConfig(config, args);
const bindings = config.bindings?.(args);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(args).length > 0) {
const binding = getBinding(args, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
return appConfig;
}
export function registerAsyncsExecutionContext(
app: App,
ctx: { waitUntil: ExecutionContext["waitUntil"] },
) {
app.emgr.onEvent(
App.Events.AppBeforeResponse,
async (event) => {
ctx.waitUntil(event.params.app.emgr.executeAsyncs());
},
{
mode: "sync",
id: constants.exec_async_event_id,
},
);
}
+9 -4
View File
@@ -1,8 +1,12 @@
import { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext, constants } from "../config";
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
{ env, ctx, ...args }: Context<Env>,
) {
const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app";
@@ -16,10 +20,11 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
const app = await createRuntimeApp(
{
...makeCfConfig(config, { env, ctx, ...args }),
...makeConfig(config, env),
initialConfig,
onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => {
registerAsyncsExecutionContext(app, ctx);
app.module.server.client.get(constants.cache_endpoint, async (c) => {
await kv.delete(key);
return c.json({ message: "Cache cleared" });
});
+9 -8
View File
@@ -1,9 +1,13 @@
import { DurableObject } from "cloudflare:workers";
import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { constants, registerAsyncsExecutionContext } from "../config";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
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";
@@ -17,7 +21,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = makeConfig(config, ctx);
const create_config = makeConfig(config, ctx.env);
const res = await stub.fire(ctx.request, {
config: create_config,
@@ -67,7 +71,8 @@ export class DurableBkndApp extends DurableObject {
this.app = await createRuntimeApp({
...config,
onBuilt: async (app) => {
app.modules.server.get("/__do", async (c) => {
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({
@@ -92,7 +97,6 @@ export class DurableBkndApp extends DurableObject {
this.keepAlive(options.keepAliveSeconds);
}
console.log("id", this.id);
const res = await this.app!.fetch(request);
const headers = new Headers(res.headers);
headers.set("X-BuildTime", buildtime.toString());
@@ -109,16 +113,13 @@ export class DurableBkndApp extends DurableObject {
async beforeBuild(app: App) {}
protected keepAlive(seconds: number) {
console.log("keep alive for", seconds);
if (this.interval) {
console.log("clearing, there is a new");
clearInterval(this.interval);
}
let i = 0;
this.interval = setInterval(() => {
i += 1;
//console.log("keep-alive", i);
if (i === seconds) {
console.log("cleared");
clearInterval(this.interval);
+37 -16
View File
@@ -1,27 +1,48 @@
import type { App } from "bknd";
import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
import { makeConfig, registerAsyncsExecutionContext } from "../config";
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp(
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return await createRuntimeApp<Env>(
{
...makeCfConfig(config, ctx),
...makeConfig(config, args),
adminOptions: config.html ? { html: config.html } : undefined,
},
ctx,
args,
opts,
);
}
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
const app = await makeApp(config, ctx);
export async function getWarm<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
const app = await makeApp(
{
...config,
onBuilt: async (app) => {
registerAsyncsExecutionContext(app, ctx.ctx);
config.onBuilt?.(app);
},
},
ctx.env,
opts,
);
return app.fetch(ctx.request);
}
let warm_app: App;
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
if (!warm_app) {
warm_app = await makeApp(config, ctx);
}
return warm_app.fetch(ctx.request);
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
config: CloudflareBkndConfig<Env>,
ctx: Context<Env>,
opts: RuntimeOptions = {},
) {
return await getWarm(config, ctx, {
...opts,
force: true,
});
}
@@ -1,7 +1,7 @@
import { createWriteStream, readFileSync } from "node:fs";
import { test } from "node:test";
import { Miniflare } from "miniflare";
import { StorageR2Adapter } from "adapter/cloudflare/StorageR2Adapter";
import { StorageR2Adapter } from "./StorageR2Adapter";
import { adapterTestSuite } from "media";
import { nodeTestRunner } from "adapter/node";
import path from "node:path";
@@ -23,7 +23,7 @@ test("StorageR2Adapter", async () => {
const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
const adapter = new StorageR2Adapter(bucket);
const basePath = path.resolve(import.meta.dirname, "../_assets");
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
const buffer = readFileSync(path.join(basePath, "image.png"));
const file = new File([buffer], "image.png", { type: "image/png" });
@@ -1,10 +1,8 @@
import { registries } from "bknd";
import { isDebug } from "bknd/core";
import { StringEnum, Type } from "bknd/utils";
import type { FileBody } from "media/storage/Storage";
import { StorageAdapter } from "media/storage/StorageAdapter";
import { guess } from "media/storage/mime-types-tiny";
import { getBindings } from "./bindings";
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
import { getBindings } from "../bindings";
export function makeSchema(bindings: string[] = []) {
return Type.Object(
+80 -43
View File
@@ -12,76 +12,113 @@ export type BkndConfig<Args = any> = CreateAppConfig & {
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];
adminOptions?: AdminControllerOptions | false;
};
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
export type DefaultArgs = {
[key: string]: any;
};
export function makeConfig<Args = DefaultArgs>(
config: BkndConfig<Args>,
args?: Args,
): CreateAppConfig {
let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) {
if (typeof config.app === "function") {
const { app, ...rest } = config;
if (app) {
if (typeof app === "function") {
if (!args) {
throw new Error("args is required when config.app is a function");
}
additionalConfig = config.app(args);
additionalConfig = app(args);
} else {
additionalConfig = config.app;
additionalConfig = app;
}
}
return { ...config, ...additionalConfig };
return { ...rest, ...additionalConfig };
}
export async function createFrameworkApp<Args = any>(
config: FrameworkBkndConfig,
// 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 app = App.create(makeConfig(config, args));
const id = opts?.id ?? "app";
let app = apps.get(id);
if (!app || opts?.force) {
app = App.create(makeConfig(config, args));
apps.set(id, app);
}
return app;
}
if (config.onBuilt) {
export async function createFrameworkApp<Args = DefaultArgs>(
config: FrameworkBkndConfig = {},
args?: Args,
opts?: FrameworkOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
if (!app.isBuilt()) {
if (config.onBuilt) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await config.onBuilt?.(app);
},
"sync",
);
}
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
}
return app;
}
export async function createRuntimeApp<Args = DefaultArgs>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
args?: Args,
opts?: RuntimeOptions,
): Promise<App> {
const app = await createAdapterApp(config, args, opts);
if (!app.isBuilt()) {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
if (serveStatic) {
const [path, handler] = Array.isArray(serveStatic)
? serveStatic
: [$config.server.assets_path + "*", serveStatic];
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
},
"sync",
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
}
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
export async function createRuntimeApp<Env = any>(
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig,
env?: Env,
): Promise<App> {
const app = App.create(makeConfig(config, env));
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
if (serveStatic) {
const [path, handler] = Array.isArray(serveStatic)
? serveStatic
: [$config.server.assets_path + "*", serveStatic];
app.modules.server.get(path, handler);
}
await config.onBuilt?.(app);
if (adminOptions !== false) {
app.registerAdminController(adminOptions);
}
},
"sync",
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
}
@@ -0,0 +1,16 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as nextjs from "./nextjs.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import type { NextjsBkndConfig } from "./nextjs.adapter";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("nextjs adapter", () => {
adapterTestSuite<NextjsBkndConfig>(bunTestRunner, {
makeApp: nextjs.getApp,
makeHandler: nextjs.serve,
});
});
+17 -32
View File
@@ -1,36 +1,19 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { isNode } from "core/utils";
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
import { isNode } from "bknd/utils";
import type { NextApiRequest } from "next";
export type NextjsBkndConfig = FrameworkBkndConfig & {
type NextjsEnv = NextApiRequest["env"];
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
cleanRequest?: { searchParams?: string[] };
};
type NextjsContext = {
env: Record<string, string | undefined>;
};
let app: App;
let building: boolean = false;
export async function getApp<Args extends NextjsContext = NextjsContext>(
config: NextjsBkndConfig,
args?: Args,
export async function getApp<Env = NextjsEnv>(
config: NextjsBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (app) return app;
}
building = true;
if (!app) {
app = await createFrameworkApp(config, args);
await app.build();
}
building = false;
return app;
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
}
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
@@ -56,11 +39,13 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
});
}
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
export function serve<Env = NextjsEnv>(
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (req: Request) => {
if (!app) {
app = await getApp(config, { env: process.env ?? {} });
}
const app = await getApp(config, args, opts);
const request = getCleanRequest(req, cleanRequest);
return app.fetch(request);
};
+10 -1
View File
@@ -5,6 +5,15 @@ export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export { nodeTestRunner } from "./test";
let registered = false;
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
if (!registered) {
registries.media.register("local", StorageLocalAdapter);
registered = true;
}
return (config: Partial<LocalAdapterConfig> = {}) => {
const adapter = new StorageLocalAdapter(config);
return adapter.toJSON(true);
};
}
@@ -0,0 +1,15 @@
import { describe, before, after } from "node:test";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { nodeTestRunner } from "adapter/node";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
before(() => disableConsoleLog());
after(enableConsoleLog);
describe("node adapter", () => {
adapterTestSuite(nodeTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
});
});
+15
View File
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as node from "./node.adapter";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("node adapter (bun)", () => {
adapterTestSuite(bunTestRunner, {
makeApp: node.createApp,
makeHandler: node.createHandler,
});
});
+36 -23
View File
@@ -2,11 +2,11 @@ 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/index";
import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
import { config as $config } from "bknd/core";
export type NodeBkndConfig = RuntimeBkndConfig & {
type NodeEnv = NodeJS.ProcessEnv;
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
port?: number;
hostname?: string;
listener?: Parameters<typeof honoServe>[1];
@@ -14,14 +14,11 @@ export type NodeBkndConfig = RuntimeBkndConfig & {
relativeDistPath?: string;
};
export function serve({
distPath,
relativeDistPath,
port = $config.server.default_port,
hostname,
listener,
...config
}: NodeBkndConfig = {}) {
export async function createApp<Env = NodeEnv>(
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
const root = path.relative(
process.cwd(),
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
@@ -30,23 +27,39 @@ export function serve({
console.warn("relativeDistPath is deprecated, please use distPath instead");
}
let app: App;
registerLocalMediaAdapter();
return await createRuntimeApp(
{
...config,
serveStatic: serveStatic({ root }),
},
// @ts-ignore
args ?? { env: process.env },
opts,
);
}
export function createHandler<Env = NodeEnv>(
config: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
return async (req: Request) => {
const app = await createApp(config, args ?? (process.env as Env), opts);
return app.fetch(req);
};
}
export function serve<Env = NodeEnv>(
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: RuntimeOptions,
) {
honoServe(
{
port,
hostname,
fetch: async (req: Request) => {
if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({
...config,
serveStatic: serveStatic({ root }),
});
}
return app.fetch(req);
},
fetch: createHandler(config, args, opts),
},
(connInfo) => {
console.log(`Server is running on http://localhost:${connInfo.port}`);
@@ -3,6 +3,7 @@ import { StorageLocalAdapter } from "./StorageLocalAdapter";
// @ts-ignore
import { assetsPath, assetsTmpPath } from "../../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
describe("StorageLocalAdapter (bun)", async () => {
const adapter = new StorageLocalAdapter({
@@ -10,5 +11,5 @@ describe("StorageLocalAdapter (bun)", async () => {
});
const file = Bun.file(`${assetsPath}/image.png`);
await adapterTestSuite({ test, expect }, adapter, file);
await adapterTestSuite(bunTestRunner, adapter, file);
});
@@ -7,14 +7,14 @@ export const localAdapterConfig = Type.Object(
{
path: Type.String({ default: "./" }),
},
{ title: "Local", description: "Local file system storage" },
{ title: "Local", description: "Local file system storage", additionalProperties: false },
);
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
export class StorageLocalAdapter extends StorageAdapter {
private config: LocalAdapterConfig;
constructor(config: any) {
constructor(config: Partial<LocalAdapterConfig> = {}) {
super();
this.config = parse(localAdapterConfig, config);
}
+24
View File
@@ -2,6 +2,17 @@ import nodeAssert from "node:assert/strict";
import { test } from "node:test";
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
// Track mock function calls
const mockCalls = new WeakMap<Function, number>();
function createMockFunction<T extends (...args: any[]) => any>(fn: T): T {
const mockFn = (...args: Parameters<T>) => {
const currentCalls = mockCalls.get(mockFn) || 0;
mockCalls.set(mockFn, currentCalls + 1);
return fn(...args);
};
return mockFn as T;
}
const nodeTestMatcher = <T = unknown>(actual: T, parentFailMsg?: string) =>
({
toEqual: (expected: T, failMsg = parentFailMsg) => {
@@ -23,6 +34,18 @@ const nodeTestMatcher = <T = unknown>(actual: T, parentFailMsg?: string) =>
const e = Array.isArray(expected) ? expected : [expected];
nodeAssert.ok(e.includes(actual), failMsg);
},
toHaveBeenCalled: (failMsg = parentFailMsg) => {
const calls = mockCalls.get(actual as Function) || 0;
nodeAssert.ok(calls > 0, failMsg || "Expected function to have been called at least once");
},
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
const calls = mockCalls.get(actual as Function) || 0;
nodeAssert.strictEqual(
calls,
expected,
failMsg || `Expected function to have been called ${expected} times`,
);
},
}) satisfies Matcher<T>;
const nodeTestResolverProxy = <T = unknown>(
@@ -63,6 +86,7 @@ nodeTest.skipIf = (condition: boolean): Test => {
export const nodeTestRunner: TestRunner = {
test: nodeTest,
mock: createMockFunction,
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
...nodeTestMatcher(actual, failMsg),
resolves: nodeTestResolverProxy(actual as Promise<T>, {
@@ -0,0 +1,15 @@
import { afterAll, beforeAll, describe } from "bun:test";
import * as rr from "./react-router.adapter";
import { disableConsoleLog, enableConsoleLog } from "core/utils";
import { adapterTestSuite } from "adapter/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("react-router adapter", () => {
adapterTestSuite(bunTestRunner, {
makeApp: rr.getApp,
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
});
});
@@ -1,39 +1,26 @@
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import type { FrameworkOptions } from "adapter";
type ReactRouterContext = {
type ReactRouterEnv = NodeJS.ProcessEnv;
type ReactRouterFunctionArgs = {
request: Request;
};
export type ReactRouterBkndConfig<Args = ReactRouterContext> = FrameworkBkndConfig<Args>;
export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<Env>;
let app: App;
let building: boolean = false;
export async function getApp<Args extends ReactRouterContext = ReactRouterContext>(
config: ReactRouterBkndConfig<Args>,
args?: Args,
export async function getApp<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env>,
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (app) return app;
}
building = true;
if (!app) {
app = await createFrameworkApp(config, args);
await app.build();
}
building = false;
return app;
return await createFrameworkApp(config, args ?? process.env, opts);
}
export function serve<Args extends ReactRouterContext = ReactRouterContext>(
config: ReactRouterBkndConfig<Args> = {},
export function serve<Env = ReactRouterEnv>(
config: ReactRouterBkndConfig<Env> = {},
args: Env = {} as Env,
opts?: FrameworkOptions,
) {
return async (args: Args) => {
app = await getApp(config, args);
return app.fetch(args.request);
return async (fnArgs: ReactRouterFunctionArgs) => {
return (await getApp(config, args, opts)).fetch(fnArgs.request);
};
}
+3 -3
View File
@@ -1,4 +1,4 @@
import type { Permission } from "core";
import { $console, type Permission } from "core";
import { patternMatch } from "core/utils";
import type { Context } from "hono";
import { createMiddleware } from "hono/factory";
@@ -49,7 +49,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.warn(`auth middleware already registered for ${getPath(c)}`);
} else {
authCtx.registered = true;
@@ -93,7 +93,7 @@ export const permission = (
if (app?.module.auth.enabled) {
throw new Error(msg);
} else {
console.warn(msg);
$console.warn(msg);
}
} else if (!authCtx.skip) {
const guard = app.modules.ctx().guard;
+26
View File
@@ -24,6 +24,32 @@ export async function overrideJson<File extends object = object>(
await writeFile(pkgPath, JSON.stringify(newPkg, null, opts?.indent || 2));
}
export async function upsertEnvFile(
kv: Record<string, string | number>,
opts?: { dir?: string; file?: string },
) {
const file = opts?.file ?? ".env";
const envPath = path.resolve(opts?.dir ?? process.cwd(), file);
const current: Record<string, string | number> = {};
try {
const values = await readFile(envPath, "utf-8");
const lines = values.split("\n");
for (const line of lines) {
const [key, value] = line.split("=");
if (key && value) {
current[key] = value;
}
}
} catch (e) {
await writeFile(envPath, "");
}
const newEnv = { ...current, ...kv };
const lines = Object.entries(newEnv).map(([key, value]) => `${key}=${value}`);
await writeFile(envPath, lines.join("\n"));
}
export async function overridePackageJson(
fn: (pkg: TPackageJson) => Promise<TPackageJson> | TPackageJson,
opts?: { dir?: string },
@@ -0,0 +1,85 @@
import * as $p from "@clack/prompts";
import { upsertEnvFile } from "cli/commands/create/npm";
import { typewriter } from "cli/utils/cli";
import c from "picocolors";
import type { Template } from ".";
import open from "open";
export const aws = {
key: "aws",
title: "AWS Lambda Basic",
integration: "aws",
description: "A basic bknd AWS Lambda starter",
path: "gh:bknd-io/bknd/examples/aws-lambda",
ref: true,
setup: async (ctx) => {
await $p.stream.info(
(async function* () {
yield* typewriter("You need a running LibSQL instance for this adapter to work.");
})(),
);
const choice = await $p.select({
message: "How do you want to proceed?",
options: [
{ label: "Enter instance details", value: "enter" },
{ label: "Create a new instance", value: "new" },
],
});
if ($p.isCancel(choice)) {
process.exit(1);
}
if (choice === "new") {
await $p.stream.info(
(async function* () {
yield* typewriter(c.dim("Proceed on turso.tech to create your instance."));
})(),
);
await open("https://sqlite.new");
}
const url = await $p.text({
message: "Enter database URL",
placeholder: "libsql://<instance>.turso.io",
validate: (v) => {
if (!v) {
return "Invalid URL";
}
return;
},
});
if ($p.isCancel(url)) {
process.exit(1);
}
const token = await $p.text({
message: "Enter database token",
placeholder: "eyJhbGciOiJIUzI1NiIsInR...",
validate: (v) => {
if (!v) {
return "";
}
return;
},
});
if ($p.isCancel(token)) {
process.exit(1);
}
await upsertEnvFile(
{
DB_URL: url,
DB_TOKEN: token ?? "",
},
{ dir: ctx.dir },
);
await $p.stream.info(
(async function* () {
yield* typewriter(`Connection details written to ${c.cyan(".env")}`);
})(),
);
},
} as const satisfies Template;
@@ -78,6 +78,12 @@ async function createD1(ctx: TemplateSetupCtx) {
process.exit(1);
}
await $p.stream.info(
(async function* () {
yield* typewriter("Now running wrangler to create a D1 database...");
})(),
);
exec(`npx wrangler d1 create ${name}`);
await $p.stream.info(
(async function* () {
+2 -1
View File
@@ -72,7 +72,8 @@ export async function getConfigPath(filePath?: string) {
}
}
const paths = ["./bknd.config", "./bknd.config.ts", "./bknd.config.js"];
const exts = ["", ".js", ".ts", ".mjs", ".cjs", ".json"];
const paths = exts.map((e) => `bknd.config${e}`);
for (const p of paths) {
const _p = path.resolve(process.cwd(), p);
if (await fileExists(_p)) {
+27 -21
View File
@@ -7,6 +7,7 @@ import { colorizeConsole, config } from "core";
import dotenv from "dotenv";
import { registries } from "modules/registries";
import c from "picocolors";
import path from "node:path";
import {
PLATFORMS,
type Platform,
@@ -15,8 +16,12 @@ import {
getConnectionCredentialsFromEnv,
startServer,
} from "./platform";
import { makeConfig } from "adapter";
dotenv.config();
const env_files = [".env", ".dev.vars"];
dotenv.config({
path: env_files.map((file) => path.resolve(process.cwd(), file)),
});
const isBun = typeof Bun !== "undefined";
export const run: CliCommand = (program) => {
@@ -85,24 +90,12 @@ async function makeApp(config: MakeAppConfig) {
return app;
}
export async function makeConfigApp(config: CliBkndConfig, platform?: Platform) {
const appConfig = typeof config.app === "function" ? config.app(process.env) : config.app;
const app = App.create(appConfig);
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
await attachServeStatic(app, platform ?? "node");
app.registerAdminController();
await config.onBuilt?.(app);
},
"sync",
);
await config.beforeBuild?.(app);
await app.build(config.buildConfig);
return app;
export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) {
const config = makeConfig(_config, { env: process.env });
return makeApp({
...config,
server: { platform },
});
}
async function action(options: {
@@ -118,19 +111,31 @@ async function action(options: {
const configFilePath = await getConfigPath(options.config);
let app: App | undefined = undefined;
// first start from arguments if given
if (options.dbUrl) {
console.info("Using connection from", c.cyan("--db-url"));
const connection = options.dbUrl
? { url: options.dbUrl, authToken: options.dbToken }
: undefined;
app = await makeApp({ connection, server: { platform: options.server } });
// check configuration file to be present
} else if (configFilePath) {
console.info("Using config from", c.cyan(configFilePath));
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server);
try {
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server);
} catch (e) {
console.error("Failed to load config:", e);
process.exit(1);
}
// 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 } });
// finally try to use env variables
} else {
const credentials = getConnectionCredentialsFromEnv();
if (credentials) {
@@ -139,6 +144,7 @@ async function action(options: {
}
}
// if nothing helps, create a file based app
if (!app) {
const connection = { url: "file:data.db" } as Config;
console.info("Using connection", c.cyan(connection.url));
+2 -5
View File
@@ -1,12 +1,9 @@
import type { CreateAppConfig } from "App";
import type { FrameworkBkndConfig } from "adapter";
import type { BkndConfig } from "adapter";
import type { Command } from "commander";
export type CliCommand = (program: Command) => void;
export type CliBkndConfig<Env = any> = FrameworkBkndConfig & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
setAdminHtml?: boolean;
export type CliBkndConfig<Env = any> = BkndConfig & {
server?: {
port?: number;
platform?: "node" | "bun";
+41 -15
View File
@@ -65,27 +65,53 @@ function __tty(_type: any, args: any[]) {
}
export type TConsoleSeverity = keyof typeof __consoles;
const level = env("cli_log_level", "log");
declare global {
var __consoleConfig:
| {
level: TConsoleSeverity;
id?: string;
}
| undefined;
}
// Ensure the config exists only once globally
const defaultLevel = env("cli_log_level", "log") as TConsoleSeverity;
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
const config = (globalThis.__consoleConfig ??= {
level: defaultLevel,
//id: crypto.randomUUID(), // for debugging
});
const keys = Object.keys(__consoles);
export const $console = new Proxy(
{},
{
get: (_, prop) => {
if (prop === "original") {
export const $console = new Proxy(config as any, {
get: (_, prop) => {
switch (prop) {
case "original":
return console;
}
case "setLevel":
return (l: TConsoleSeverity) => {
config.level = l;
};
case "resetLevel":
return () => {
config.level = defaultLevel;
};
}
const current = keys.indexOf(level as string);
const requested = keys.indexOf(prop as string);
if (prop in __consoles && requested <= current) {
return (...args: any[]) => __tty(prop, args);
}
return () => null;
},
const current = keys.indexOf(config.level);
const requested = keys.indexOf(prop as string);
if (prop in __consoles && requested <= current) {
return (...args: any[]) => __tty(prop, args);
}
return () => null;
},
) as typeof console & {
}) as typeof console & {
original: typeof console;
} & {
setLevel: (l: TConsoleSeverity) => void;
resetLevel: () => void;
};
export function colorizeConsole(con: typeof console) {
+12 -6
View File
@@ -22,6 +22,7 @@ export class EventManager<
protected events: EventClass[] = [];
protected listeners: EventListener[] = [];
enabled: boolean = true;
protected asyncs: (() => Promise<void>)[] = [];
constructor(
events?: RegisteredEvents,
@@ -29,7 +30,6 @@ export class EventManager<
listeners?: EventListener[];
onError?: (event: Event, e: unknown) => void;
onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void;
asyncExecutor?: typeof Promise.all;
},
) {
if (events) {
@@ -176,9 +176,15 @@ export class EventManager<
this.events.forEach((event) => this.onEvent(event, handler, config));
}
protected executeAsyncs(promises: (() => Promise<void>)[]) {
const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e));
executor(promises.map((p) => p())).then(() => void 0);
protected collectAsyncs(promises: (() => Promise<void>)[]) {
this.asyncs.push(...promises);
}
async executeAsyncs(executor: typeof Promise.all = (e) => Promise.all(e)): Promise<void> {
if (this.asyncs.length === 0) return;
const asyncs = [...this.asyncs];
this.asyncs = [];
await executor(asyncs.map((p) => p()));
}
async emit<Actual extends Event<any, any>>(event: Actual): Promise<Actual> {
@@ -209,8 +215,8 @@ export class EventManager<
return !listener.once;
});
// execute asyncs
this.executeAsyncs(asyncs);
// collect asyncs
this.collectAsyncs(asyncs);
// execute syncs
let _event: Actual = event;
+3
View File
@@ -5,6 +5,8 @@ export type Matcher<T = unknown> = {
toBeString: (failMsg?: string) => void;
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg?: string) => void;
toBeDefined: (failMsg?: string) => void;
toHaveBeenCalled: (failMsg?: string) => void;
toHaveBeenCalledTimes: (expected: number, failMsg?: string) => void;
};
export type TestFn = (() => void | Promise<unknown>) | ((done: (err?: unknown) => void) => void);
export interface Test {
@@ -15,6 +17,7 @@ export interface Test {
}
export type TestRunner = {
test: Test;
mock: <T extends (...args: any[]) => any>(fn: T) => T | any;
expect: <T = unknown>(
actual?: T,
failMsg?: string,
+4 -9
View File
@@ -1,3 +1,5 @@
import { $console } from "core";
type ConsoleSeverity = "log" | "warn" | "error";
const _oldConsoles = {
log: console.log,
@@ -34,21 +36,14 @@ export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"
severities.forEach((severity) => {
console[severity] = () => null;
});
return enableConsoleLog;
$console.setLevel("error");
}
export function enableConsoleLog() {
Object.entries(_oldConsoles).forEach(([severity, fn]) => {
console[severity as ConsoleSeverity] = fn;
});
}
export function tryit(fn: () => void, fallback?: any) {
try {
return fn();
} catch (e) {
return fallback || e;
}
$console.resetLevel();
}
export function formatMemoryUsage() {
@@ -6,6 +6,7 @@ import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysel
import type { QB } from "../Connection";
import { SqliteConnection } from "./SqliteConnection";
import { SqliteIntrospector } from "./SqliteIntrospector";
import { $console } from "core";
export const LIBSQL_PROTOCOLS = ["wss", "https", "libsql"] as const;
export type LibSqlCredentials = Config & {
@@ -33,6 +34,7 @@ export class LibsqlConnection extends SqliteConnection {
constructor(credentials: LibSqlCredentials);
constructor(clientOrCredentials: Client | LibSqlCredentials) {
let client: Client;
let batching_enabled = true;
if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
@@ -42,6 +44,13 @@ export class LibsqlConnection extends SqliteConnection {
}
client = createClient({ url, authToken });
// currently there is an issue in limbo implementation
// that prevents batching from working correctly
if (/\.aws.*turso\.io$/.test(url)) {
$console.warn("Using an Turso AWS endpoint currently disables batching support");
batching_enabled = false;
}
} else {
client = clientOrCredentials;
}
@@ -54,6 +63,7 @@ export class LibsqlConnection extends SqliteConnection {
super(kysely, {}, plugins);
this.client = client;
this.supported.batching = batching_enabled;
}
getClient(): Client {
+80 -31
View File
@@ -27,9 +27,9 @@ export type RepositoryResponse<T = EntityData[]> = RepositoryRawResponse & {
entity: Entity;
data: T;
meta: {
total: number;
count: number;
items: number;
total?: number;
count?: number;
time?: number;
query?: {
sql: string;
@@ -47,6 +47,7 @@ export type RepositoryExistsResponse = RepositoryRawResponse & {
export type RepositoryOptions = {
silent?: boolean;
includeCounts?: boolean;
emgr?: EventManager<any>;
};
@@ -59,13 +60,17 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
constructor(
public em: EntityManager<TBD>,
public entity: Entity,
protected options?: RepositoryOptions,
protected options: RepositoryOptions = {},
) {
this.emgr = options?.emgr ?? new EventManager(MutatorEvents);
}
private cloneFor(entity: Entity) {
return new Repository(this.em, this.em.entity(entity), { emgr: this.emgr });
private cloneFor(entity: Entity, opts: Partial<RepositoryOptions> = {}) {
return new Repository(this.em, this.em.entity(entity), {
...this.options,
...opts,
emgr: this.emgr,
});
}
private get conn() {
@@ -172,28 +177,39 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
if (options.limit) validated.limit = options.limit;
if (options.offset) validated.offset = options.offset;
//$console.debug("Repository: options", { entity: entity.name, options, validated });
return validated;
}
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
protected async executeQb(qb: RepositoryQB) {
const compiled = qb.compile();
if (this.options?.silent !== true) {
$console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters);
}
const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.selectFrom(entity.name).select(selector("total"));
let result: any;
try {
result = await qb.execute();
} catch (e) {
if (this.options?.silent !== true) {
if (e instanceof Error) {
$console.error("[ERROR] Repository.executeQb", e.message);
}
throw e;
}
}
return {
result,
sql: compiled.sql,
parameters: [...compiled.parameters],
};
}
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity;
const compiled = qb.compile();
const payload = {
entity,
sql: compiled.sql,
@@ -209,13 +225,53 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
},
};
// don't batch (add counts) if `includeCounts` is set to false
// or when explicitly set to true and batching is not supported
if (
this.options?.includeCounts === false ||
(this.options?.includeCounts === true && !this.em.connection.supports("batching"))
) {
const start = performance.now();
const res = await this.executeQb(qb);
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const result = res.result ?? [];
const data = this.em.hydrate(entity.name, result);
return {
...payload,
result,
data,
meta: {
...payload.meta,
total: undefined,
count: undefined,
items: data.length,
time,
},
};
}
if (this.options?.silent !== true) {
$console.debug(`Repository: query\n${compiled.sql}\n`, compiled.parameters);
}
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
const countQuery = qb
.clearSelect()
.select(selector())
.clearLimit()
.clearOffset()
.clearGroupBy()
.clearOrderBy();
const totalQuery = this.conn.selectFrom(entity.name).select(selector());
try {
const start = performance.now();
const [_count, _total, result] = await this.em.connection.batchQuery([
countQuery,
totalQuery,
qb,
]);
//$console.log("result", { _count, _total });
const time = Number.parseFloat((performance.now() - start).toFixed(2));
const data = this.em.hydrate(entity.name, result);
@@ -227,7 +283,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
meta: {
...payload.meta,
// parsing is important since pg returns string
total: ensureInt(_total[0]?.total),
total: ensureInt(_total[0]?.count),
count: ensureInt(_count[0]?.count),
items: result.length,
time,
@@ -435,8 +491,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
qb = WhereBuilder.addClause(qb, options.where);
}
const compiled = qb.compile();
const result = await qb.execute();
const { result, ...compiled } = await this.executeQb(qb);
return {
sql: compiled.sql,
@@ -454,15 +509,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
let qb = this.conn.selectFrom(entity.name).select(selector);
// add mandatory where
qb = WhereBuilder.addClause(qb, options.where);
qb = WhereBuilder.addClause(qb, options.where).limit(1);
// we only need 1
qb = qb.limit(1);
const compiled = qb.compile();
//$console.log("exists query", compiled.sql, compiled.parameters);
const result = await qb.execute();
//$console.log("result", result);
const { result, ...compiled } = await this.executeQb(qb);
return {
sql: compiled.sql,
+6 -2
View File
@@ -6,6 +6,7 @@ import type { ModuleConfigs } from "modules";
import {
BooleanField,
type BooleanFieldConfig,
type Connection,
DateField,
type DateFieldConfig,
Entity,
@@ -171,8 +172,6 @@ export class FieldPrototype {
}
}
//type Entity<Fields extends Record<string, Field<any, any>> = {}> = { name: string; fields: Fields };
export function entity<
EntityName extends string,
Fields extends Record<string, Field<any, any, any>>,
@@ -270,6 +269,10 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
) {
super(Object.values(__entities), new DummyConnection(), relations, indices);
}
withConnection(connection: Connection): EntityManager<Schema<Entities>> {
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
}
}
type Chained<R extends Record<string, (...args: any[]) => any>> = {
@@ -326,6 +329,7 @@ export function em<Entities extends Record<string, Entity>>(
entities: e.__entities,
relations,
indices,
proto: e,
toJSON: () =>
e.toJSON() as unknown as Pick<ModuleConfigs["data"], "entities" | "relations" | "indices">,
};
@@ -4,6 +4,7 @@ import { config } from "dotenv";
// @ts-ignore
import { assetsPath, assetsTmpPath } from "../../../../../__test__/helper";
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
import { bunTestRunner } from "adapter/bun/test";
const dotenvOutput = config({ path: `${import.meta.dir}/.env` });
const {
@@ -43,7 +44,7 @@ describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", async () => {
});
});
await adapterTestSuite({ test, expect }, adapter, file, {
await adapterTestSuite(bunTestRunner, adapter, file, {
// eventual consistency
retries: 20,
retryTimeout: 1000,
@@ -4,6 +4,7 @@ import { StorageS3Adapter } from "./StorageS3Adapter";
import { config } from "dotenv";
import { adapterTestSuite } from "media";
import { assetsPath } from "../../../../../__test__/helper";
import { bunTestRunner } from "adapter/bun/test";
//import { enableFetchLogging } from "../../helper";
const dotenvOutput = config({ path: `${import.meta.dir}/.env` });
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
@@ -45,6 +46,6 @@ describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
const file = Bun.file(`${assetsPath}/image.png`) as unknown as File;
describe.each(versions)("%s", async (_name, adapter) => {
await adapterTestSuite({ test, expect }, adapter, file);
await adapterTestSuite(bunTestRunner, adapter, file);
});
});
+1 -1
View File
@@ -19,7 +19,7 @@ export function getRandomizedFilename(file: File | string, length = 16): string
}
let ext = getExtensionFromName(filename);
if (isFile(file) && file.type) {
if (!ext && isFile(file) && file.type) {
const _ext = extension(file.type);
if (_ext.length > 0) ext = _ext;
}
+2
View File
@@ -222,6 +222,8 @@ export class ModuleManager {
return this.__em.repo(__bknd, {
// to prevent exceptions when table doesn't exist
silent: true,
// disable counts for performance and compatibility
includeCounts: false,
});
}
+4 -4
View File
@@ -66,15 +66,15 @@ const Skeleton = ({ theme }: { theme?: any }) => {
<Logo theme={actualTheme} />
</div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => (
{[...new Array(4)].map((item, key) => (
<AppShell.NavLink key={key} as="span" className="active h-full opacity-50">
<div className="w-10 h-3" />
<div className="w-18 h-3" />
</AppShell.NavLink>
))}
</nav>
<nav className="flex md:hidden flex-row items-center">
<nav className="flex md:hidden flex-row gap-2.5 pl-0 p-2.5 items-center">
<AppShell.NavLink as="span" className="active h-full opacity-50">
<div className="w-10 h-3" />
<div className="w-20 h-3" />
</AppShell.NavLink>
</nav>
<div className="flex flex-grow" />
+2 -2
View File
@@ -5,7 +5,7 @@ import { Form } from "json-schema-form-react";
import { transform } from "lodash-es";
import type { ComponentPropsWithoutRef } from "react";
import { Button } from "ui/components/buttons/Button";
import { Group, Input, Label } from "ui/components/form/Formy/components";
import { Group, Input, Password, Label } from "ui/components/form/Formy/components";
import { SocialLink } from "./SocialLink";
import type { ValueError } from "@sinclair/typebox/value";
import { type TSchema, Value } from "core/utils";
@@ -99,7 +99,7 @@ export function AuthForm({
</Group>
<Group>
<Label htmlFor="password">Password</Label>
<Input type="password" name="password" />
<Password name="password" />
</Group>
<Button
+14 -2
View File
@@ -44,8 +44,7 @@ export function MediaInfoModal({
return (
<div className="flex flex-col md:flex-row">
<div className="flex w-full md:w-[calc(100%-300px)] justify-center items-center bg-lightest min-w-0">
{/* @ts-ignore */}
<Media.Preview file={file} className="max-h-[70dvh]" controls muted />
<FilePreview file={file} />
</div>
<div className="w-full md:!w-[300px] flex flex-col">
<Item title="ID" value={data?.id} copyValue={origin + mediaUrl} first>
@@ -157,6 +156,19 @@ const Item = ({
);
};
const FilePreview = ({ file }: { file: FileState }) => {
if (file.type.startsWith("image/") || file.type.startsWith("video/")) {
// @ts-ignore
return <Media.Preview file={file} className="max-h-[70dvh]" controls muted />;
}
return (
<div className="min-w-96 min-h-48 flex justify-center items-center h-full max-h-[70dvh]">
<span className="opacity-50 font-mono">No Preview Available</span>
</div>
);
};
MediaInfoModal.defaultTitle = undefined;
MediaInfoModal.modalProps = {
withCloseButton: false,
+2 -1
View File
@@ -1,4 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"exclude": ["./node_modules", "./__test__"]
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { serveStatic } from "@hono/node-server/serve-static";
import { showRoutes } from "hono/dev";
import { App, registries } from "./src";
import { StorageLocalAdapter } from "./src/adapter/node/storage/StorageLocalAdapter";
import { StorageLocalAdapter } from "./src/adapter/node";
import { EntityManager, LibsqlConnection } from "data";
import { __bknd } from "modules/ModuleManager";