diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1030c9a0..6fbec3c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v1 with: - bun-version: latest + bun-version: "1.2.5" - name: Install dependencies working-directory: ./app diff --git a/app/.gitignore b/app/.gitignore index 259a2668..863eab7d 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1,3 @@ playwright-report -test-results \ No newline at end of file +test-results +bknd.config.* \ No newline at end of file diff --git a/app/__test__/adapter/adapter.test.ts b/app/__test__/adapter/adapter.test.ts new file mode 100644 index 00000000..734fb084 --- /dev/null +++ b/app/__test__/adapter/adapter.test.ts @@ -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", + }); +}); diff --git a/app/__test__/api/DataApi.spec.ts b/app/__test__/api/DataApi.spec.ts index c6bb3df7..51786ca8 100644 --- a/app/__test__/api/DataApi.spec.ts +++ b/app/__test__/api/DataApi.spec.ts @@ -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 () => { diff --git a/app/__test__/app/App.spec.ts b/app/__test__/app/App.spec.ts index c5e97940..860258ad 100644 --- a/app/__test__/app/App.spec.ts +++ b/app/__test__/app/App.spec.ts @@ -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(); + }); }); diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index 995ebfa0..3d8b981f 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -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(); }); diff --git a/app/__test__/data/specs/Mutator.spec.ts b/app/__test__/data/specs/Mutator.spec.ts index 4b3bee7d..7110956d 100644 --- a/app/__test__/data/specs/Mutator.spec.ts +++ b/app/__test__/data/specs/Mutator.spec.ts @@ -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(); }); diff --git a/app/__test__/data/specs/Repository.spec.ts b/app/__test__/data/specs/Repository.spec.ts index 2a42b9e3..f50ca837 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -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 | Transaction; @@ -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(); diff --git a/app/__test__/helper.ts b/app/__test__/helper.ts index 405e46f7..16b8b8ee 100644 --- a/app/__test__/helper.ts +++ b/app/__test__/helper.ts @@ -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; diff --git a/app/__test__/media/Storage.spec.ts b/app/__test__/media/Storage.spec.ts index f4936067..12341238 100644 --- a/app/__test__/media/Storage.spec.ts +++ b/app/__test__/media/Storage.spec.ts @@ -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 = {}; 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 diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index 37f29a8a..dd13f7c5 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -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"); }); }); diff --git a/app/package.json b/app/package.json index 33edff47..18d7f059 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/App.ts b/app/src/App.ts index ac0ea1d8..d8d147ae 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -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; @@ -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; manager?: Omit; + 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: 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) { + 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 = {}) { diff --git a/app/src/adapter/adapter-test-suite.ts b/app/src/adapter/adapter-test-suite.ts new file mode 100644 index 00000000..0ddb2b72 --- /dev/null +++ b/app/src/adapter/adapter-test-suite.ts @@ -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; + makeHandler?: ( + config?: Config, + args?: Args, + opts?: RuntimeOptions | FrameworkOptions, + ) => (request: Request) => Promise; + 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) => { + 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("*"); + }); + } +} diff --git a/app/src/adapter/astro/astro.adapter.spec.ts b/app/src/adapter/astro/astro.adapter.spec.ts new file mode 100644 index 00000000..3f3d1e83 --- /dev/null +++ b/app/src/adapter/astro/astro.adapter.spec.ts @@ -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 }), + }); +}); diff --git a/app/src/adapter/astro/astro.adapter.ts b/app/src/adapter/astro/astro.adapter.ts index 61971e3f..a684f738 100644 --- a/app/src/adapter/astro/astro.adapter.ts +++ b/app/src/adapter/astro/astro.adapter.ts @@ -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 = FrameworkBkndConfig; +import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter"; +type AstroEnv = NodeJS.ProcessEnv; type TAstro = { request: Request; }; +export type AstroBkndConfig = FrameworkBkndConfig; -export type Options = { - mode?: "static" | "dynamic"; -} & Omit & { - 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( + config: AstroBkndConfig = {}, + args: Env = {} as Env, + opts: FrameworkOptions = {}, +) { + return await createFrameworkApp(config, args ?? import.meta.env, opts); } -let app: App; -export function serve(config: AstroBkndConfig = {}) { - return async (args: Context) => { - if (!app) { - app = await createFrameworkApp(config, args); - } - return app.fetch(args.request); +export function serve( + config: AstroBkndConfig = {}, + args: Env = {} as Env, + opts?: FrameworkOptions, +) { + return async (fnArgs: TAstro) => { + return (await getApp(config, args, opts)).fetch(fnArgs.request); }; } diff --git a/app/src/adapter/aws/aws-lambda.adapter.ts b/app/src/adapter/aws/aws-lambda.adapter.ts index 9488065d..f73b76ea 100644 --- a/app/src/adapter/aws/aws-lambda.adapter.ts +++ b/app/src/adapter/aws/aws-lambda.adapter.ts @@ -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 = + RuntimeBkndConfig & { + 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 = { - adminOptions, - }; +export async function createApp( + { adminOptions = false, assets, ...config }: AwsLambdaBkndConfig = {}, + args: Env = {} as Env, + opts?: RuntimeOptions, +): Promise { + let additional: Partial = { + 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( + config: AwsLambdaBkndConfig = {}, + 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; diff --git a/app/src/adapter/aws/aws.adapter.spec.ts b/app/src/adapter/aws/aws.adapter.spec.ts new file mode 100644 index 00000000..e6873d8d --- /dev/null +++ b/app/src/adapter/aws/aws.adapter.spec.ts @@ -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); + }, + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.spec.ts b/app/src/adapter/bun/bun.adapter.spec.ts new file mode 100644 index 00000000..74231900 --- /dev/null +++ b/app/src/adapter/bun/bun.adapter.spec.ts @@ -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, + }); +}); diff --git a/app/src/adapter/bun/bun.adapter.ts b/app/src/adapter/bun/bun.adapter.ts index 2087c5ee..44fb795f 100644 --- a/app/src/adapter/bun/bun.adapter.ts +++ b/app/src/adapter/bun/bun.adapter.ts @@ -1,47 +1,64 @@ /// 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 = RuntimeBkndConfig & Omit; -export type BunBkndConfig = RuntimeBkndConfig & Omit; - -export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) { +export async function createApp( + { distPath, ...config }: BunBkndConfig = {}, + 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( + config: BunBkndConfig = {}, + 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( + { + distPath, + connection, + initialConfig, + options, + port = config.server.default_port, + onBuilt, + buildConfig, + adminOptions, + ...serveOptions + }: BunBkndConfig = {}, + 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}`); diff --git a/app/src/adapter/bun/test.ts b/app/src/adapter/bun/test.ts new file mode 100644 index 00000000..7bd314a6 --- /dev/null +++ b/app/src/adapter/bun/test.ts @@ -0,0 +1,7 @@ +import { expect, test, mock } from "bun:test"; + +export const bunTestRunner = { + expect, + test, + mock, +}; diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts new file mode 100644 index 00000000..22449a48 --- /dev/null +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts @@ -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(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); + }; + }, + }); +}); diff --git a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 7483d523..f1ac9e61 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,18 +1,16 @@ /// -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 = FrameworkBkndConfig> & { +export type CloudflareEnv = object; +export type CloudflareBkndConfig = FrameworkBkndConfig & { mode?: "warm" | "fresh" | "cache" | "durable"; - bindings?: (args: Context) => { + bindings?: (args: Env) => { kv?: KVNamespace; dobj?: DurableObjectNamespace; db?: D1Database; @@ -26,45 +24,15 @@ export type CloudflareBkndConfig = FrameworkBkndConfig> html?: string; }; -export type Context = { +export type Context = { 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(config: CloudflareBkndConfig = {}) { +export function serve( + config: CloudflareBkndConfig = {}, +) { return { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); @@ -99,7 +67,7 @@ export function serve(config: CloudflareBkndConfig = {}) { } } - const context = { request, env, ctx } as Context; + const context = { request, env, ctx } as Context; const mode = config.mode ?? "warm"; switch (mode) { diff --git a/app/src/adapter/cloudflare/config.ts b/app/src/adapter/cloudflare/config.ts new file mode 100644 index 00000000..4b9f3d7f --- /dev/null +++ b/app/src/adapter/cloudflare/config.ts @@ -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( + config: CloudflareBkndConfig, + 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, + }, + ); +} diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index c126ff7c..47911249 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -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( + config: CloudflareBkndConfig, + { env, ctx, ...args }: Context, +) { 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" }); }); diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 63fce345..d09a0060 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -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( + config: CloudflareBkndConfig, + ctx: Context, +) { 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); diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index b13c5372..b5730b18 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -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( + config: CloudflareBkndConfig, + args: Env = {} as Env, + opts?: RuntimeOptions, +) { + return await createRuntimeApp( { - ...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( + config: CloudflareBkndConfig, + ctx: Context, + 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( + config: CloudflareBkndConfig, + ctx: Context, + opts: RuntimeOptions = {}, +) { + return await getWarm(config, ctx, { + ...opts, + force: true, + }); } diff --git a/app/__test__/media/StorageR2Adapter.native-spec.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts similarity index 88% rename from app/__test__/media/StorageR2Adapter.native-spec.ts rename to app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts index 4e3b95b6..16c4fa54 100644 --- a/app/__test__/media/StorageR2Adapter.native-spec.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.native-spec.ts @@ -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" }); diff --git a/app/src/adapter/cloudflare/StorageR2Adapter.ts b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts similarity index 96% rename from app/src/adapter/cloudflare/StorageR2Adapter.ts rename to app/src/adapter/cloudflare/storage/StorageR2Adapter.ts index 6020d928..62c41f69 100644 --- a/app/src/adapter/cloudflare/StorageR2Adapter.ts +++ b/app/src/adapter/cloudflare/storage/StorageR2Adapter.ts @@ -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( diff --git a/app/src/adapter/index.ts b/app/src/adapter/index.ts index 6d416e92..84fb8c51 100644 --- a/app/src/adapter/index.ts +++ b/app/src/adapter/index.ts @@ -12,76 +12,113 @@ export type BkndConfig = CreateAppConfig & { export type FrameworkBkndConfig = BkndConfig; +export type CreateAdapterAppOptions = { + force?: boolean; + id?: string; +}; +export type FrameworkOptions = CreateAdapterAppOptions; +export type RuntimeOptions = CreateAdapterAppOptions; + export type RuntimeBkndConfig = BkndConfig & { distPath?: string; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; adminOptions?: AdminControllerOptions | false; }; -export function makeConfig(config: BkndConfig, args?: Args): CreateAppConfig { +export type DefaultArgs = { + [key: string]: any; +}; + +export function makeConfig( + config: BkndConfig, + 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( - config: FrameworkBkndConfig, +// a map that contains all apps by id +const apps = new Map(); +export async function createAdapterApp( + config: Config = {} as Config, args?: Args, + opts?: CreateAdapterAppOptions, ): Promise { - 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( + config: FrameworkBkndConfig = {}, + args?: Args, + opts?: FrameworkOptions, +): Promise { + 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( + { serveStatic, adminOptions, ...config }: RuntimeBkndConfig = {}, + args?: Args, + opts?: RuntimeOptions, +): Promise { + 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( - { serveStatic, adminOptions, ...config }: RuntimeBkndConfig, - env?: Env, -): Promise { - 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; } diff --git a/app/src/adapter/nextjs/nextjs.adapter.spec.ts b/app/src/adapter/nextjs/nextjs.adapter.spec.ts new file mode 100644 index 00000000..8e3f2e4a --- /dev/null +++ b/app/src/adapter/nextjs/nextjs.adapter.spec.ts @@ -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(bunTestRunner, { + makeApp: nextjs.getApp, + makeHandler: nextjs.serve, + }); +}); diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 32ff1028..2b3b8298 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -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 = FrameworkBkndConfig & { cleanRequest?: { searchParams?: string[] }; }; -type NextjsContext = { - env: Record; -}; - -let app: App; -let building: boolean = false; - -export async function getApp( - config: NextjsBkndConfig, - args?: Args, +export async function getApp( + config: NextjsBkndConfig, + 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( + { cleanRequest, ...config }: NextjsBkndConfig = {}, + 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); }; diff --git a/app/src/adapter/node/index.ts b/app/src/adapter/node/index.ts index e0470403..c009c075 100644 --- a/app/src/adapter/node/index.ts +++ b/app/src/adapter/node/index.ts @@ -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 = {}) => { + const adapter = new StorageLocalAdapter(config); + return adapter.toJSON(true); + }; } diff --git a/app/src/adapter/node/node.adapter.native-spec.ts b/app/src/adapter/node/node.adapter.native-spec.ts new file mode 100644 index 00000000..c4ece3bf --- /dev/null +++ b/app/src/adapter/node/node.adapter.native-spec.ts @@ -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, + }); +}); diff --git a/app/src/adapter/node/node.adapter.spec.ts b/app/src/adapter/node/node.adapter.spec.ts new file mode 100644 index 00000000..4050c687 --- /dev/null +++ b/app/src/adapter/node/node.adapter.spec.ts @@ -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, + }); +}); diff --git a/app/src/adapter/node/node.adapter.ts b/app/src/adapter/node/node.adapter.ts index 97a8b82f..816eb92c 100644 --- a/app/src/adapter/node/node.adapter.ts +++ b/app/src/adapter/node/node.adapter.ts @@ -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 = RuntimeBkndConfig & { port?: number; hostname?: string; listener?: Parameters[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( + { distPath, relativeDistPath, ...config }: NodeBkndConfig = {}, + 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( + config: NodeBkndConfig = {}, + 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( + { port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig = {}, + 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}`); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts index ea76d9c4..b231c15b 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.spec.ts @@ -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); }); diff --git a/app/src/adapter/node/storage/StorageLocalAdapter.ts b/app/src/adapter/node/storage/StorageLocalAdapter.ts index 12b91ce9..8a44f404 100644 --- a/app/src/adapter/node/storage/StorageLocalAdapter.ts +++ b/app/src/adapter/node/storage/StorageLocalAdapter.ts @@ -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; export class StorageLocalAdapter extends StorageAdapter { private config: LocalAdapterConfig; - constructor(config: any) { + constructor(config: Partial = {}) { super(); this.config = parse(localAdapterConfig, config); } diff --git a/app/src/adapter/node/test.ts b/app/src/adapter/node/test.ts index e2c39226..992cbeea 100644 --- a/app/src/adapter/node/test.ts +++ b/app/src/adapter/node/test.ts @@ -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 createMockFunction any>(fn: T): T { + const mockFn = (...args: Parameters) => { + const currentCalls = mockCalls.get(mockFn) || 0; + mockCalls.set(mockFn, currentCalls + 1); + return fn(...args); + }; + return mockFn as T; +} + const nodeTestMatcher = (actual: T, parentFailMsg?: string) => ({ toEqual: (expected: T, failMsg = parentFailMsg) => { @@ -23,6 +34,18 @@ const nodeTestMatcher = (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; const nodeTestResolverProxy = ( @@ -63,6 +86,7 @@ nodeTest.skipIf = (condition: boolean): Test => { export const nodeTestRunner: TestRunner = { test: nodeTest, + mock: createMockFunction, expect: (actual?: T, failMsg?: string) => ({ ...nodeTestMatcher(actual, failMsg), resolves: nodeTestResolverProxy(actual as Promise, { diff --git a/app/src/adapter/react-router/react-router.adapter.spec.ts b/app/src/adapter/react-router/react-router.adapter.spec.ts new file mode 100644 index 00000000..25ef8955 --- /dev/null +++ b/app/src/adapter/react-router/react-router.adapter.spec.ts @@ -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 }), + }); +}); diff --git a/app/src/adapter/react-router/react-router.adapter.ts b/app/src/adapter/react-router/react-router.adapter.ts index 7e796c68..4474509a 100644 --- a/app/src/adapter/react-router/react-router.adapter.ts +++ b/app/src/adapter/react-router/react-router.adapter.ts @@ -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 = FrameworkBkndConfig; +export type ReactRouterBkndConfig = FrameworkBkndConfig; -let app: App; -let building: boolean = false; - -export async function getApp( - config: ReactRouterBkndConfig, - args?: Args, +export async function getApp( + config: ReactRouterBkndConfig, + 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( - config: ReactRouterBkndConfig = {}, +export function serve( + config: ReactRouterBkndConfig = {}, + 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); }; } diff --git a/app/src/auth/middlewares.ts b/app/src/auth/middlewares.ts index 83e64ce9..dcd09949 100644 --- a/app/src/auth/middlewares.ts +++ b/app/src/auth/middlewares.ts @@ -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; diff --git a/app/src/cli/commands/create/npm.ts b/app/src/cli/commands/create/npm.ts index 690d8918..7722e1c9 100644 --- a/app/src/cli/commands/create/npm.ts +++ b/app/src/cli/commands/create/npm.ts @@ -24,6 +24,32 @@ export async function overrideJson( await writeFile(pkgPath, JSON.stringify(newPkg, null, opts?.indent || 2)); } +export async function upsertEnvFile( + kv: Record, + opts?: { dir?: string; file?: string }, +) { + const file = opts?.file ?? ".env"; + const envPath = path.resolve(opts?.dir ?? process.cwd(), file); + const current: Record = {}; + + 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, opts?: { dir?: string }, diff --git a/app/src/cli/commands/create/templates/aws.ts b/app/src/cli/commands/create/templates/aws.ts new file mode 100644 index 00000000..e4a8b928 --- /dev/null +++ b/app/src/cli/commands/create/templates/aws.ts @@ -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://.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; diff --git a/app/src/cli/commands/create/templates/cloudflare.ts b/app/src/cli/commands/create/templates/cloudflare.ts index 0e4dee8e..8bbc234e 100644 --- a/app/src/cli/commands/create/templates/cloudflare.ts +++ b/app/src/cli/commands/create/templates/cloudflare.ts @@ -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* () { diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 62707bd7..480a979a 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -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)) { diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index ff15f101..8962c3be 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -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)); diff --git a/app/src/cli/types.d.ts b/app/src/cli/types.d.ts index 30bde3aa..b1c20ea3 100644 --- a/app/src/cli/types.d.ts +++ b/app/src/cli/types.d.ts @@ -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 = FrameworkBkndConfig & { - app: CreateAppConfig | ((env: Env) => CreateAppConfig); - setAdminHtml?: boolean; +export type CliBkndConfig = BkndConfig & { server?: { port?: number; platform?: "node" | "bun"; diff --git a/app/src/core/console.ts b/app/src/core/console.ts index 2d8e11b8..daa7587f 100644 --- a/app/src/core/console.ts +++ b/app/src/core/console.ts @@ -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: +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) { diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index c2de8c92..1c20e58b 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -22,6 +22,7 @@ export class EventManager< protected events: EventClass[] = []; protected listeners: EventListener[] = []; enabled: boolean = true; + protected asyncs: (() => Promise)[] = []; 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)[]) { - const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e)); - executor(promises.map((p) => p())).then(() => void 0); + protected collectAsyncs(promises: (() => Promise)[]) { + this.asyncs.push(...promises); + } + + async executeAsyncs(executor: typeof Promise.all = (e) => Promise.all(e)): Promise { + if (this.asyncs.length === 0) return; + const asyncs = [...this.asyncs]; + this.asyncs = []; + await executor(asyncs.map((p) => p())); } async emit>(event: Actual): Promise { @@ -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; diff --git a/app/src/core/test/index.ts b/app/src/core/test/index.ts index 22485ef6..ca1ffbae 100644 --- a/app/src/core/test/index.ts +++ b/app/src/core/test/index.ts @@ -5,6 +5,8 @@ export type Matcher = { toBeString: (failMsg?: string) => void; toBeOneOf: (expected: T | Array | Iterable, failMsg?: string) => void; toBeDefined: (failMsg?: string) => void; + toHaveBeenCalled: (failMsg?: string) => void; + toHaveBeenCalledTimes: (expected: number, failMsg?: string) => void; }; export type TestFn = (() => void | Promise) | ((done: (err?: unknown) => void) => void); export interface Test { @@ -15,6 +17,7 @@ export interface Test { } export type TestRunner = { test: Test; + mock: any>(fn: T) => T | any; expect: ( actual?: T, failMsg?: string, diff --git a/app/src/core/utils/test.ts b/app/src/core/utils/test.ts index f5cb0e89..c022592c 100644 --- a/app/src/core/utils/test.ts +++ b/app/src/core/utils/test.ts @@ -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() { diff --git a/app/src/data/connection/sqlite/LibsqlConnection.ts b/app/src/data/connection/sqlite/LibsqlConnection.ts index 895b6b09..75183571 100644 --- a/app/src/data/connection/sqlite/LibsqlConnection.ts +++ b/app/src/data/connection/sqlite/LibsqlConnection.ts @@ -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 { diff --git a/app/src/data/entities/query/Repository.ts b/app/src/data/entities/query/Repository.ts index 47657f87..d16b7b12 100644 --- a/app/src/data/entities/query/Repository.ts +++ b/app/src/data/entities/query/Repository.ts @@ -27,9 +27,9 @@ export type RepositoryResponse = 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; }; @@ -59,13 +60,17 @@ export class Repository, 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 = {}) { + 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 { - 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().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 { + const entity = this.entity; + const compiled = qb.compile(); + const payload = { entity, sql: compiled.sql, @@ -209,13 +225,53 @@ export class Repository this.conn.fn.countAll().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> = {}> = { name: string; fields: Fields }; - export function entity< EntityName extends string, Fields extends Record>, @@ -270,6 +269,10 @@ class EntityManagerPrototype> extends En ) { super(Object.values(__entities), new DummyConnection(), relations, indices); } + + withConnection(connection: Connection): EntityManager> { + return new EntityManager(this.entities, connection, this.relations.all, this.indices); + } } type Chained any>> = { @@ -326,6 +329,7 @@ export function em>( entities: e.__entities, relations, indices, + proto: e, toJSON: () => e.toJSON() as unknown as Pick, }; diff --git a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts index 207e0304..455e6d4f 100644 --- a/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts +++ b/app/src/media/storage/adapters/cloudinary/StorageCloudinaryAdapter.spec.ts @@ -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, diff --git a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts index a23744b9..f0b1b521 100644 --- a/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts +++ b/app/src/media/storage/adapters/s3/StorageS3Adapter.spec.ts @@ -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); }); }); diff --git a/app/src/media/utils/index.ts b/app/src/media/utils/index.ts index 575fd1a1..d9867dbd 100644 --- a/app/src/media/utils/index.ts +++ b/app/src/media/utils/index.ts @@ -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; } diff --git a/app/src/modules/ModuleManager.ts b/app/src/modules/ModuleManager.ts index 7c3b0ed1..cfd9e573 100644 --- a/app/src/modules/ModuleManager.ts +++ b/app/src/modules/ModuleManager.ts @@ -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, }); } diff --git a/app/src/ui/Admin.tsx b/app/src/ui/Admin.tsx index 03121775..59bf8596 100644 --- a/app/src/ui/Admin.tsx +++ b/app/src/ui/Admin.tsx @@ -66,15 +66,15 @@ const Skeleton = ({ theme }: { theme?: any }) => { -