From 11a28eba887195eb333ef7c7ee4023f76789526d Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 28 Mar 2025 18:03:09 +0100 Subject: [PATCH 01/11] improve cli creds extraction --- app/.gitignore | 3 ++ app/src/cli/commands/run/platform.ts | 5 +-- app/src/cli/commands/run/run.ts | 48 ++++++++++++++++------------ app/src/cli/types.d.ts | 7 ++-- 4 files changed, 35 insertions(+), 28 deletions(-) create mode 100644 app/.gitignore diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..74f7dc35 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,3 @@ +test-results +playwright-report +bknd.config.* \ No newline at end of file diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 62707bd7..90b8358c 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -72,9 +72,10 @@ 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); + const _p = path.relative(process.cwd(), p); if (await fileExists(_p)) { return _p; } diff --git a/app/src/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index ff15f101..2d156e15 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, 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"; From b29c04e8c9c868bbadaac7773cb1c035bfbc550d Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 28 Mar 2025 20:52:00 +0100 Subject: [PATCH 02/11] added more cli instructions --- app/src/cli/commands/run/platform.ts | 2 +- docs/usage/cli.mdx | 92 ++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/app/src/cli/commands/run/platform.ts b/app/src/cli/commands/run/platform.ts index 90b8358c..480a979a 100644 --- a/app/src/cli/commands/run/platform.ts +++ b/app/src/cli/commands/run/platform.ts @@ -75,7 +75,7 @@ export async function getConfigPath(filePath?: string) { const exts = ["", ".js", ".ts", ".mjs", ".cjs", ".json"]; const paths = exts.map((e) => `bknd.config${e}`); for (const p of paths) { - const _p = path.relative(process.cwd(), p); + const _p = path.resolve(process.cwd(), p); if (await fileExists(_p)) { return _p; } diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 7106774b..b203be8c 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -3,8 +3,7 @@ title: 'Using the CLI' description: 'How to start a bknd instance using the CLI.' --- -Instead of running **bknd** using a framework, you can also use the CLI to quickly spin up a -full functional instance. To see all available options, run: +The bknd package includes a command-line interface (CLI) that allows you to run a bknd instance and perform various tasks. ``` npx bknd @@ -15,18 +14,21 @@ Here is the output: $ npx bknd Usage: bknd [options] [command] -bknd cli +⚡ bknd cli v0.10.3-rc.1 Options: - -V, --version output the version number - -h, --help display help for command + -V, --version output the version number + -h, --help display help for command Commands: - user create and update user (auth) - schema [options] get schema - run [options] - config [options] get default config - help [command] display help for command + config [options] get default config + copy-assets [options] copy static assets + create [options] create a new project + debug debug bknd + run [options] run an instance + schema [options] get schema + user create and update user (auth) + help [command] display help for command ``` ## Starting an instance (`run`) @@ -38,29 +40,81 @@ Usage: bknd run [options] Options: -p, --port port to run on (default: 1337, env: PORT) + -m, --memory use in-memory database -c, --config config file --db-url database url, can be any valid libsql url --db-token database token - --server server type (choices: "node", "bun", default: "node") + --server server type (choices: "node", "bun", default: "bun") + --no-open don't open browser window on start -h, --help display help for command ``` -### In-memory database -To start an instance with an ephemeral in-memory database, run the following: -``` -npx bknd run -``` -Keep in mind that the database is not persisted and will be lost when the process is terminated. +To order in which the connection is determined is as follows: +1. `--db-url` +2. `--config` or reading the filesystem looking for `bknd.config.[js|ts|mjs|cjs|json]` +3. `--memory` +4. Environment variables `DB_URL` and `DB_TOKEN` in `.env` or `.dev.vars` +5. Fallback to file-based database `data.db` ### File-based database -To start an instance with a file-based database, run the following: +By default, a file-based database `data.db` is used when running without any arguments. You can specify a different file name or path using the `--db-url` option. The database file will be created in the current working directory if it does not exist. + ``` npx bknd run --db-url file:data.db ``` +### Using configuration file (`bknd.config.*`) +You can create a configuration file on the working directory that automatically gets picked up: `bknd.config.[js|ts|mjs|cjs|json]` + +Here is an example of a `bknd.config.ts` file: + +```ts +import type { BkndConfig } from "bknd/adapter"; + +export default { + // you can either specify the connection directly + connection: { + url: "file:data.db", + }, + // or use the `app` function which passes the environment variables + app: ({ env }) => ({ + connection: { + url: env.DB_URL, + } + }) +} satisfies BkndConfig; +``` +The `app` function is useful if you need a cross-platform way to access the environment variables. For example, on Cloudflare Workers, you can only access environment variables inside a request handler. If you're exclusively using a node-like environment, it's safe to access the environment variables directly from `process.env`. + + +If you're using `npx bknd run`, make sure to create a file in a file format that `node` can load, otherwise you may run into an error that the file couldn't be found: + +```sh +[INF] 2025-03-28 18:02:21 Using config from bknd.config.ts +[ERR] 2025-03-28 18:02:21 Failed to load config: Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'bknd.config.ts' imported from [...] + at packageResolve (node:internal/modules/esm/resolve:857:9) + at [...] { + code: 'ERR_MODULE_NOT_FOUND' +} +``` + +If you still want to use a `.ts` extension, you can start the CLI e.g. using `tsx`: + +```sh +npx tsx node_modules/.bin/bknd run +``` + ### Turso/LibSQL database To start an instance with a Turso/LibSQL database, run the following: ``` npx bknd run --db-url libsql://your-db.turso.io --db-token ``` -The `--db-token` option is optional and only required if the database is protected. \ No newline at end of file +The `--db-token` option is optional and only required if the database is protected. + + +### In-memory database +To start an instance with an ephemeral in-memory database, run the following: +``` +npx bknd run --memory +``` +Keep in mind that the database is not persisted and will be lost when the process is terminated. \ No newline at end of file From b2fd907e8ca39a60279ee54d4eb6bfd8ee76f34b Mon Sep 17 00:00:00 2001 From: dswbx Date: Fri, 28 Mar 2025 21:12:50 +0100 Subject: [PATCH 03/11] updated docs, fixed run with node/tsx --- app/__test__/helper.ts | 1 + app/package.json | 34 ++++++++++++++++----------------- app/src/cli/commands/run/run.ts | 2 +- app/tsconfig.build.json | 3 ++- docs/usage/cli.mdx | 8 +++++++- 5 files changed, 28 insertions(+), 20 deletions(-) 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/package.json b/app/package.json index 2f26ad39..9faf7ca9 100644 --- a/app/package.json +++ b/app/package.json @@ -124,52 +124,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", @@ -178,37 +178,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/cli/commands/run/run.ts b/app/src/cli/commands/run/run.ts index 2d156e15..8962c3be 100644 --- a/app/src/cli/commands/run/run.ts +++ b/app/src/cli/commands/run/run.ts @@ -91,7 +91,7 @@ async function makeApp(config: MakeAppConfig) { } export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform) { - const config = makeConfig(_config, process.env); + const config = makeConfig(_config, { env: process.env }); return makeApp({ ...config, server: { platform }, diff --git a/app/tsconfig.build.json b/app/tsconfig.build.json index 5f1c8afd..f3a19cb9 100644 --- a/app/tsconfig.build.json +++ b/app/tsconfig.build.json @@ -1,4 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["./src/**/*.ts", "./src/**/*.tsx"] + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["./node_modules", "./__test__"] } diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index b203be8c..0c19a5fe 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -98,7 +98,13 @@ If you're using `npx bknd run`, make sure to create a file in a file format that } ``` -If you still want to use a `.ts` extension, you can start the CLI e.g. using `tsx`: +If you still want to use a `.ts` extension, you can start the CLI e.g. using `node` (>=v22.6.0): + +```sh +node --experimental-strip-types node_modules/.bin/bknd run +``` + +Or with `tsx`: ```sh npx tsx node_modules/.bin/bknd run From 36e4224b3367b52d53496515c0bf79476089cb18 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 11:19:55 +0200 Subject: [PATCH 04/11] refactored EventManager to run asyncs on call only, app defaults to run before response (#129) * refactored EventManager to run asyncs on call only, app defaults to run before response * fix tests --- app/__test__/app/App.spec.ts | 85 +++++++++++++++- app/__test__/core/EventManager.spec.ts | 14 +-- app/__test__/data/specs/Mutator.spec.ts | 3 + app/__test__/data/specs/Repository.spec.ts | 13 ++- app/__test__/media/Storage.spec.ts | 8 +- app/src/App.ts | 98 +++++++++++++------ .../cloudflare/cloudflare-workers.adapter.ts | 18 +++- app/src/adapter/cloudflare/modes/cached.ts | 14 ++- app/src/adapter/cloudflare/modes/durable.ts | 20 ++-- app/src/adapter/cloudflare/modes/fresh.ts | 17 +++- app/src/core/events/EventManager.ts | 18 ++-- 11 files changed, 244 insertions(+), 64 deletions(-) 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..982187cc 100644 --- a/app/__test__/data/specs/Repository.spec.ts +++ b/app/__test__/data/specs/Repository.spec.ts @@ -198,22 +198,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__/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/src/App.ts b/app/src/App.ts index ac0ea1d8..b4e0ea5b 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?: @@ -70,35 +85,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 +202,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/cloudflare/cloudflare-workers.adapter.ts b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts index 7483d523..dec7d095 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -9,6 +9,7 @@ import { getBinding } from "./bindings"; import { getCached } from "./modes/cached"; import { getDurable } from "./modes/durable"; import { getFresh, getWarm } from "./modes/fresh"; +import type { CreateAppConfig } from "App"; export type CloudflareBkndConfig = FrameworkBkndConfig> & { mode?: "warm" | "fresh" | "cache" | "durable"; @@ -32,8 +33,14 @@ export type Context = { ctx: ExecutionContext; }; +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 makeCfConfig(config: CloudflareBkndConfig, context: Context) { +export function makeCfConfig(config: CloudflareBkndConfig, context: Context): CreateAppConfig { if (!media_registered) { registerMedia(context.env as any); media_registered = true; @@ -61,7 +68,14 @@ export function makeCfConfig(config: CloudflareBkndConfig, context: Context) { } } - return appConfig; + return { + ...appConfig, + options: { + ...appConfig.options, + // if not specified explicitly, disable it to use ExecutionContext's waitUntil + asyncEventsMode: config.options?.asyncEventsMode ?? "none", + }, + }; } export function serve(config: CloudflareBkndConfig = {}) { diff --git a/app/src/adapter/cloudflare/modes/cached.ts b/app/src/adapter/cloudflare/modes/cached.ts index c126ff7c..78d8eb4c 100644 --- a/app/src/adapter/cloudflare/modes/cached.ts +++ b/app/src/adapter/cloudflare/modes/cached.ts @@ -1,6 +1,6 @@ import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; +import { type CloudflareBkndConfig, constants, type Context, makeCfConfig } from "../index"; export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) { const { kv } = config.bindings?.(env)!; @@ -19,13 +19,23 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg ...makeCfConfig(config, { env, ctx, ...args }), initialConfig, onBuilt: async (app) => { - app.module.server.client.get("/__bknd/cache", async (c) => { + app.module.server.client.get(constants.cache_endpoint, async (c) => { await kv.delete(key); return c.json({ message: "Cache cleared" }); }); await config.onBuilt?.(app); }, beforeBuild: async (app) => { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); app.emgr.onEvent( App.Events.AppConfigUpdatedEvent, async ({ params: { app } }) => { diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 63fce345..369d4514 100644 --- a/app/src/adapter/cloudflare/modes/durable.ts +++ b/app/src/adapter/cloudflare/modes/durable.ts @@ -1,7 +1,7 @@ import { DurableObject } from "cloudflare:workers"; -import type { App, CreateAppConfig } from "bknd"; +import { App, type CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import type { CloudflareBkndConfig, Context } from "../index"; +import { type CloudflareBkndConfig, type Context, constants } from "../index"; export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { const { dobj } = config.bindings?.(ctx.env)!; @@ -67,7 +67,17 @@ export class DurableBkndApp extends DurableObject { this.app = await createRuntimeApp({ ...config, onBuilt: async (app) => { - app.modules.server.get("/__do", async (c) => { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + this.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); + 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 +102,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 +118,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..2d34d889 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,12 +1,25 @@ -import type { App } from "bknd"; +import { App } from "bknd"; import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index"; +import { type CloudflareBkndConfig, type Context, makeCfConfig, constants } from "../index"; export async function makeApp(config: CloudflareBkndConfig, ctx: Context) { return await createRuntimeApp( { ...makeCfConfig(config, ctx), adminOptions: config.html ? { html: config.html } : undefined, + onBuilt: async (app) => { + app.emgr.onEvent( + App.Events.AppBeforeResponse, + async (event) => { + ctx.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); + }, + { + mode: "sync", + id: constants.exec_async_event_id, + }, + ); + await config.onBuilt?.(app); + }, }, ctx, ); 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; From 3f26c45dd928f8710394b516c543a7c297ccd1f5 Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 11:43:11 +0200 Subject: [PATCH 05/11] refactored adapters to run test suites (#126) * refactored adapters to run test suites * fix bun version for tests * added missing adapter tests and refactored examples to use `bknd.config.ts` where applicable --- .github/workflows/test.yml | 2 +- app/__test__/adapter/adapter.test.ts | 62 +++++++++ app/package.json | 1 + app/src/App.ts | 6 +- app/src/adapter/adapter-test-suite.ts | 90 +++++++++++++ app/src/adapter/astro/astro.adapter.spec.ts | 15 +++ app/src/adapter/astro/astro.adapter.ts | 41 +++--- app/src/adapter/aws/aws-lambda.adapter.ts | 116 +++++++++-------- app/src/adapter/aws/aws.adapter.spec.ts | 19 +++ app/src/adapter/bun/bun.adapter.spec.ts | 15 +++ app/src/adapter/bun/bun.adapter.ts | 76 ++++++----- app/src/adapter/bun/test.ts | 7 + .../cloudflare-workers.adapter.spec.ts | 60 +++++++++ .../cloudflare/cloudflare-workers.adapter.ts | 64 ++------- app/src/adapter/cloudflare/config.ts | 64 +++++++++ app/src/adapter/cloudflare/modes/cached.ts | 21 ++- app/src/adapter/cloudflare/modes/durable.ts | 23 ++-- app/src/adapter/cloudflare/modes/fresh.ts | 66 +++++----- .../storage}/StorageR2Adapter.native-spec.ts | 4 +- .../{ => storage}/StorageR2Adapter.ts | 6 +- app/src/adapter/index.ts | 123 ++++++++++++------ app/src/adapter/nextjs/nextjs.adapter.spec.ts | 16 +++ app/src/adapter/nextjs/nextjs.adapter.ts | 49 +++---- app/src/adapter/node/index.ts | 11 +- .../adapter/node/node.adapter.native-spec.ts | 15 +++ app/src/adapter/node/node.adapter.spec.ts | 15 +++ app/src/adapter/node/node.adapter.ts | 59 +++++---- .../node/storage/StorageLocalAdapter.spec.ts | 3 +- .../node/storage/StorageLocalAdapter.ts | 4 +- app/src/adapter/node/test.ts | 24 ++++ .../react-router/react-router.adapter.spec.ts | 15 +++ .../react-router/react-router.adapter.ts | 43 +++--- app/src/core/console.ts | 56 +++++--- app/src/core/test/index.ts | 3 + app/src/core/utils/test.ts | 5 +- .../StorageCloudinaryAdapter.spec.ts | 3 +- .../adapters/s3/StorageS3Adapter.spec.ts | 3 +- examples/astro/bknd.config.ts | 65 +++++++++ examples/astro/src/bknd.ts | 23 ++++ .../astro/src/pages/admin/[...admin].astro | 4 +- examples/astro/src/pages/api/[...api].ts | 75 +---------- examples/astro/src/pages/index.astro | 2 +- examples/astro/src/pages/ssr.astro | 4 +- examples/aws-lambda/index.mjs | 4 +- examples/aws-lambda/package.json | 38 +++--- examples/aws-lambda/test.js | 6 +- examples/bun/bun.lock | 32 +++++ examples/bun/index.ts | 5 +- examples/bun/minimal.ts | 6 - examples/cloudflare-worker/src/index.ts | 2 +- examples/nextjs/bknd.config.ts | 74 +++++++++++ examples/nextjs/src/bknd.ts | 87 +------------ examples/react-router/app/bknd.ts | 79 +---------- examples/react-router/app/routes/api.$.ts | 2 +- examples/react-router/bknd.config.ts | 64 +++++++++ 55 files changed, 1130 insertions(+), 647 deletions(-) create mode 100644 app/__test__/adapter/adapter.test.ts create mode 100644 app/src/adapter/adapter-test-suite.ts create mode 100644 app/src/adapter/astro/astro.adapter.spec.ts create mode 100644 app/src/adapter/aws/aws.adapter.spec.ts create mode 100644 app/src/adapter/bun/bun.adapter.spec.ts create mode 100644 app/src/adapter/bun/test.ts create mode 100644 app/src/adapter/cloudflare/cloudflare-workers.adapter.spec.ts create mode 100644 app/src/adapter/cloudflare/config.ts rename app/{__test__/media => src/adapter/cloudflare/storage}/StorageR2Adapter.native-spec.ts (88%) rename app/src/adapter/cloudflare/{ => storage}/StorageR2Adapter.ts (96%) create mode 100644 app/src/adapter/nextjs/nextjs.adapter.spec.ts create mode 100644 app/src/adapter/node/node.adapter.native-spec.ts create mode 100644 app/src/adapter/node/node.adapter.spec.ts create mode 100644 app/src/adapter/react-router/react-router.adapter.spec.ts create mode 100644 examples/astro/bknd.config.ts create mode 100644 examples/astro/src/bknd.ts create mode 100644 examples/bun/bun.lock delete mode 100644 examples/bun/minimal.ts create mode 100644 examples/nextjs/bknd.config.ts create mode 100644 examples/react-router/bknd.config.ts 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/__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/package.json b/app/package.json index 9faf7ca9..44c8dbde 100644 --- a/app/package.json +++ b/app/package.json @@ -19,6 +19,7 @@ "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", diff --git a/app/src/App.ts b/app/src/App.ts index b4e0ea5b..d8d147ae 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -68,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( 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 dec7d095..f1ac9e61 100644 --- a/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts +++ b/app/src/adapter/cloudflare/cloudflare-workers.adapter.ts @@ -1,19 +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"; -import type { CreateAppConfig } from "App"; -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; @@ -27,58 +24,15 @@ export type CloudflareBkndConfig = FrameworkBkndConfig> html?: string; }; -export type Context = { +export type Context = { request: Request; env: Env; ctx: ExecutionContext; }; -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 makeCfConfig(config: CloudflareBkndConfig, context: Context): CreateAppConfig { - 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, - options: { - ...appConfig.options, - // if not specified explicitly, disable it to use ExecutionContext's waitUntil - asyncEventsMode: config.options?.asyncEventsMode ?? "none", - }, - }; -} - -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); @@ -113,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 78d8eb4c..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, constants, 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,9 +20,10 @@ 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) => { + registerAsyncsExecutionContext(app, ctx); app.module.server.client.get(constants.cache_endpoint, async (c) => { await kv.delete(key); return c.json({ message: "Cache cleared" }); @@ -26,16 +31,6 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg await config.onBuilt?.(app); }, beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppBeforeResponse, - async (event) => { - ctx.waitUntil(event.params.app.emgr.executeAsyncs()); - }, - { - mode: "sync", - id: constants.exec_async_event_id, - }, - ); app.emgr.onEvent( App.Events.AppConfigUpdatedEvent, async ({ params: { app } }) => { diff --git a/app/src/adapter/cloudflare/modes/durable.ts b/app/src/adapter/cloudflare/modes/durable.ts index 369d4514..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 { App, type CreateAppConfig } from "bknd"; +import type { App, CreateAppConfig } from "bknd"; import { createRuntimeApp, makeConfig } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, constants } 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,16 +71,7 @@ export class DurableBkndApp extends DurableObject { this.app = await createRuntimeApp({ ...config, onBuilt: async (app) => { - app.emgr.onEvent( - App.Events.AppBeforeResponse, - async (event) => { - this.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); - }, - { - mode: "sync", - id: constants.exec_async_event_id, - }, - ); + 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; diff --git a/app/src/adapter/cloudflare/modes/fresh.ts b/app/src/adapter/cloudflare/modes/fresh.ts index 2d34d889..b5730b18 100644 --- a/app/src/adapter/cloudflare/modes/fresh.ts +++ b/app/src/adapter/cloudflare/modes/fresh.ts @@ -1,40 +1,48 @@ -import { App } from "bknd"; -import { createRuntimeApp } from "bknd/adapter"; -import { type CloudflareBkndConfig, type Context, makeCfConfig, constants } 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, - onBuilt: async (app) => { - app.emgr.onEvent( - App.Events.AppBeforeResponse, - async (event) => { - ctx.ctx.waitUntil(event.params.app.emgr.executeAsyncs()); - }, - { - mode: "sync", - id: constants.exec_async_event_id, - }, - ); - await config.onBuilt?.(app); - }, }, - 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/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/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..cc3b5c38 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,13 +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; }); + $console.resetLevel(); } export function tryit(fn: () => void, fallback?: any) { 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/examples/astro/bknd.config.ts b/examples/astro/bknd.config.ts new file mode 100644 index 00000000..ebe3f2a1 --- /dev/null +++ b/examples/astro/bknd.config.ts @@ -0,0 +1,65 @@ +import type { AstroBkndConfig } from "bknd/adapter/astro"; +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import { boolean, em, entity, text } from "bknd/data"; +import { secureRandomString } from "bknd/utils"; + +// since we're running in node, we can register the local media adapter +const local = registerLocalMediaAdapter(); + +// the em() function makes it easy to create an initial schema +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + // we can use any libsql config, and if omitted, uses in-memory + app: (env) => ({ + connection: { + url: env.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-astro-example", + secret: secureRandomString(64), + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies AstroBkndConfig; diff --git a/examples/astro/src/bknd.ts b/examples/astro/src/bknd.ts new file mode 100644 index 00000000..22297843 --- /dev/null +++ b/examples/astro/src/bknd.ts @@ -0,0 +1,23 @@ +import type { AstroGlobal } from "astro"; +import { getApp as getBkndApp } from "bknd/adapter/astro"; +import config from "../bknd.config"; + +export { config }; + +export async function getApp() { + return await getBkndApp(config); +} + +export async function getApi( + astro: AstroGlobal, + opts?: { mode: "static" } | { mode?: "dynamic"; verify?: boolean }, +) { + const app = await getApp(); + if (opts?.mode !== "static" && opts?.verify) { + const api = app.getApi({ headers: astro.request.headers }); + await api.verifyAuth(); + return api; + } + + return app.getApi(); +} diff --git a/examples/astro/src/pages/admin/[...admin].astro b/examples/astro/src/pages/admin/[...admin].astro index de6b15da..d672c471 100644 --- a/examples/astro/src/pages/admin/[...admin].astro +++ b/examples/astro/src/pages/admin/[...admin].astro @@ -2,9 +2,9 @@ import { Admin } from "bknd/ui"; import "bknd/dist/styles.css"; -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../../bknd"; -const api = await getApi(Astro, { mode: "dynamic" }); +const api = await getApi(Astro, { verify: true }); const user = api.getUser(); export const prerender = false; diff --git a/examples/astro/src/pages/api/[...api].ts b/examples/astro/src/pages/api/[...api].ts index 16472ca1..477270d7 100644 --- a/examples/astro/src/pages/api/[...api].ts +++ b/examples/astro/src/pages/api/[...api].ts @@ -1,77 +1,6 @@ import type { APIContext } from "astro"; -import { App } from "bknd"; import { serve } from "bknd/adapter/astro"; -import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { boolean, em, entity, text } from "bknd/data"; -import { secureRandomString } from "bknd/utils"; +import { config } from "../../bknd"; export const prerender = false; - -// since we're running in node, we can register the local media adapter -registerLocalMediaAdapter(); - -// the em() function makes it easy to create an initial schema -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -// register your schema to get automatic type completion -type Database = (typeof schema)["DB"]; -declare module "bknd/core" { - interface DB extends Database {} -} - -export const ALL = serve({ - // we can use any libsql config, and if omitted, uses in-memory - connection: { - url: "file:data.db", - }, - // an initial config is only applied if the database is empty - initialConfig: { - data: schema.toJSON(), - // we're enabling auth ... - auth: { - enabled: true, - jwt: { - issuer: "bknd-astro-example", - secret: secureRandomString(64), - }, - }, - // ... and media - media: { - enabled: true, - adapter: { - type: "local", - config: { - path: "./public", - }, - }, - }, - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - }, - }, - // here we can hook into the app lifecycle events ... - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppFirstBoot, - async () => { - // ... to create an initial user - await app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - }); - }, - "sync", - ); - }, -}); +export const ALL = serve(config); diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro index 854df7f1..56ae737d 100644 --- a/examples/astro/src/pages/index.astro +++ b/examples/astro/src/pages/index.astro @@ -1,5 +1,5 @@ --- -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../bknd"; import Card from "../components/Card.astro"; import Layout from "../layouts/Layout.astro"; diff --git a/examples/astro/src/pages/ssr.astro b/examples/astro/src/pages/ssr.astro index ca8f3800..027f48ff 100644 --- a/examples/astro/src/pages/ssr.astro +++ b/examples/astro/src/pages/ssr.astro @@ -1,8 +1,8 @@ --- -import { getApi } from "bknd/adapter/astro"; +import { getApi } from "../bknd"; import Card from "../components/Card.astro"; import Layout from "../layouts/Layout.astro"; -const api = await getApi(Astro, { mode: "dynamic" }); +const api = await getApi(Astro, { verify: true }); const { data } = await api.data.readMany("todos"); const user = api.getUser(); diff --git a/examples/aws-lambda/index.mjs b/examples/aws-lambda/index.mjs index 9f665450..0f76fd82 100644 --- a/examples/aws-lambda/index.mjs +++ b/examples/aws-lambda/index.mjs @@ -1,6 +1,6 @@ -import { serveLambda } from "bknd/adapter/aws"; +import { serve } from "bknd/adapter/aws"; -export const handler = serveLambda({ +export const handler = serve({ // to get local assets, run `npx bknd copy-assets` // this is automatically done in `deploy.sh` assets: { diff --git a/examples/aws-lambda/package.json b/examples/aws-lambda/package.json index 1f7bc028..0d7fa09c 100644 --- a/examples/aws-lambda/package.json +++ b/examples/aws-lambda/package.json @@ -1,21 +1,21 @@ { - "name": "aws-lambda", - "version": "1.0.0", - "main": "index.mjs", - "scripts": { - "test": "esbuild index.mjs --bundle --format=cjs --platform=node --external:fs --outfile=dist/index.js && node test.js", - "deploy": "./deploy.sh", - "clean": "./clean.sh" - }, - "keywords": [], - "author": "", - "license": "ISC", - "description": "", - "dependencies": { - "bknd": "file:../../app/bknd-0.9.0-rc.1-11.tgz" - }, - "devDependencies": { - "esbuild": "^0.25.0", - "dotenv": "^16.4.7" - } + "name": "aws-lambda", + "version": "1.0.0", + "main": "index.mjs", + "scripts": { + "test": "esbuild index.mjs --bundle --format=cjs --platform=node --external:fs --outfile=dist/index.js && node test.js", + "deploy": "./deploy.sh", + "clean": "./clean.sh" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "bknd": "file:../../app" + }, + "devDependencies": { + "esbuild": "^0.25.0", + "dotenv": "^16.4.7" + } } diff --git a/examples/aws-lambda/test.js b/examples/aws-lambda/test.js index a7d586ba..48208044 100644 --- a/examples/aws-lambda/test.js +++ b/examples/aws-lambda/test.js @@ -3,11 +3,11 @@ const handler = require("./dist/index.js").handler; const event = { httpMethod: "GET", - path: "/", - //path: "/api/system/config", + //path: "/", + path: "/api/system/config", //path: "/assets/main-B6sEDlfs.js", headers: { - //"Content-Type": "application/json", + "Content-Type": "application/json", "User-Agent": "curl/7.64.1", Accept: "*/*", }, diff --git a/examples/bun/bun.lock b/examples/bun/bun.lock new file mode 100644 index 00000000..a8cad87f --- /dev/null +++ b/examples/bun/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "bun", + "dependencies": { + "bknd": "file:../../app", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5.0.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.2", "", { "dependencies": { "bun-types": "1.2.2" } }, "sha512-tr74gdku+AEDN5ergNiBnplr7hpDp3V1h7fqI2GcR/rsUaM39jpSeKH0TFibRvU0KwniRx5POgaYnaXbk0hU+w=="], + + "@types/node": ["@types/node@22.13.4", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg=="], + + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], + + "bknd": ["/app@file:../../app", { "devDependencies": { "@types/node": "^22.10.0" }, "bin": { "bknd": "dist/cli/index.js" } }], + + "bun-types": ["bun-types@1.2.2", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-RCbMH5elr9gjgDGDhkTTugA21XtJAy/9jkKe/G3WR2q17VPGhcquf9Sir6uay9iW+7P/BV0CAHA1XlHXMAVKHg=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + } +} diff --git a/examples/bun/index.ts b/examples/bun/index.ts index 33750190..44f9e8f8 100644 --- a/examples/bun/index.ts +++ b/examples/bun/index.ts @@ -1,4 +1,3 @@ -// @ts-ignore somehow causes types:build issues on app import { type BunBkndConfig, serve } from "bknd/adapter/bun"; // Actually, all it takes is the following line: @@ -7,8 +6,8 @@ import { type BunBkndConfig, serve } from "bknd/adapter/bun"; // this is optional, if omitted, it uses an in-memory database const config: BunBkndConfig = { connection: { - url: "file:data.db" - } + url: "file:data.db", + }, }; serve(config); diff --git a/examples/bun/minimal.ts b/examples/bun/minimal.ts deleted file mode 100644 index 65175f99..00000000 --- a/examples/bun/minimal.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createApp } from "bknd"; - -const app = createApp(); -await app.build(); - -export default app; diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index 5e489d1c..b9b9e762 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -6,5 +6,5 @@ export default serve({ mode: "warm", onBuilt: async (app) => { app.modules.server.get("/custom", (c) => c.json({ hello: "world" })); - } + }, }); diff --git a/examples/nextjs/bknd.config.ts b/examples/nextjs/bknd.config.ts new file mode 100644 index 00000000..68d15b5d --- /dev/null +++ b/examples/nextjs/bknd.config.ts @@ -0,0 +1,74 @@ +import type { NextjsBkndConfig } from "bknd/adapter/nextjs"; +import { boolean, em, entity, text } from "bknd/data"; +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import { secureRandomString } from "bknd/utils"; + +// The local media adapter works well in development, and server based +// deployments. However, on vercel or any other serverless deployments, +// you shouldn't use a filesystem based media adapter. +// +// Additionally, if you run the bknd api on the "edge" runtime, +// this would not work as well. +// +// For production, it is recommended to uncomment the line below. +const local = registerLocalMediaAdapter(); + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + app: (env) => ({ + connection: { + url: env.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-nextjs-example", + secret: secureRandomString(64), + }, + cookie: { + pathSuccess: "/ssr", + pathLoggedOut: "/ssr", + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies NextjsBkndConfig; diff --git a/examples/nextjs/src/bknd.ts b/examples/nextjs/src/bknd.ts index 24061f70..d38baa4f 100644 --- a/examples/nextjs/src/bknd.ts +++ b/examples/nextjs/src/bknd.ts @@ -1,90 +1,11 @@ -import { type NextjsBkndConfig, getApp as getBkndApp } from "bknd/adapter/nextjs"; -import { App } from "bknd"; -import { boolean, em, entity, text } from "bknd/data"; -import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { secureRandomString } from "bknd/utils"; +import { getApp as getBkndApp } from "bknd/adapter/nextjs"; import { headers } from "next/headers"; +import config from "../bknd.config"; -// The local media adapter works well in development, and server based -// deployments. However, on vercel or any other serverless deployments, -// you shouldn't use a filesystem based media adapter. -// -// Additionally, if you run the bknd api on the "edge" runtime, -// this would not work as well. -// -// For production, it is recommended to uncomment the line below. -registerLocalMediaAdapter(); - -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -// register your schema to get automatic type completion -type Database = (typeof schema)["DB"]; -declare module "bknd/core" { - interface DB extends Database {} -} - -export const config = { - connection: { - url: "file:data.db", - }, - // an initial config is only applied if the database is empty - initialConfig: { - data: schema.toJSON(), - // we're enabling auth ... - auth: { - enabled: true, - jwt: { - issuer: "bknd-nextjs-example", - secret: secureRandomString(64), - }, - cookie: { - pathSuccess: "/ssr", - pathLoggedOut: "/ssr", - }, - }, - // ... and media - media: { - enabled: true, - adapter: { - type: "local", - config: { - path: "./public", - }, - }, - }, - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - }, - }, - // here we can hook into the app lifecycle events ... - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppFirstBoot, - async () => { - // ... to create an initial user - await app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - }); - }, - "sync", - ); - }, -} as const satisfies NextjsBkndConfig; +export { config }; export async function getApp() { - return await getBkndApp(config); + return await getBkndApp(config, process.env); } export async function getApi(opts?: { verify?: boolean }) { diff --git a/examples/react-router/app/bknd.ts b/examples/react-router/app/bknd.ts index 6819cad3..0676bb12 100644 --- a/examples/react-router/app/bknd.ts +++ b/examples/react-router/app/bknd.ts @@ -1,79 +1,8 @@ -import { App } from "bknd"; -import { registerLocalMediaAdapter } from "bknd/adapter/node"; -import { type ReactRouterBkndConfig, getApp as getBkndApp } from "bknd/adapter/react-router"; -import { boolean, em, entity, text } from "bknd/data"; -import { secureRandomString } from "bknd/utils"; +import { getApp as getBkndApp } from "bknd/adapter/react-router"; +import config from "../bknd.config"; -// since we're running in node, we can register the local media adapter -registerLocalMediaAdapter(); - -const schema = em({ - todos: entity("todos", { - title: text(), - done: boolean(), - }), -}); - -// register your schema to get automatic type completion -type Database = (typeof schema)["DB"]; -declare module "bknd/core" { - interface DB extends Database {} -} - -const config = { - // we can use any libsql config, and if omitted, uses in-memory - connection: { - url: "file:test.db", - }, - // an initial config is only applied if the database is empty - initialConfig: { - data: schema.toJSON(), - // we're enabling auth ... - auth: { - enabled: true, - jwt: { - issuer: "bknd-remix-example", - secret: secureRandomString(64), - }, - }, - // ... and media - media: { - enabled: true, - adapter: { - type: "local", - config: { - path: "./public", - }, - }, - }, - }, - options: { - // the seed option is only executed if the database was empty - seed: async (ctx) => { - await ctx.em.mutator("todos").insertMany([ - { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false }, - ]); - }, - }, - // here we can hook into the app lifecycle events ... - beforeBuild: async (app) => { - app.emgr.onEvent( - App.Events.AppFirstBoot, - async () => { - // ... to create an initial user - await app.module.auth.createUser({ - email: "test@bknd.io", - password: "12345678", - }); - }, - "sync", - ); - }, -} as const satisfies ReactRouterBkndConfig; - -export async function getApp(args?: { request: Request }) { - return await getBkndApp(config, args); +export async function getApp() { + return await getBkndApp(config, process.env as any); } export async function getApi(args?: { request: Request }, opts?: { verify?: boolean }) { diff --git a/examples/react-router/app/routes/api.$.ts b/examples/react-router/app/routes/api.$.ts index d01a6de7..ee808480 100644 --- a/examples/react-router/app/routes/api.$.ts +++ b/examples/react-router/app/routes/api.$.ts @@ -1,7 +1,7 @@ import { getApp } from "~/bknd"; const handler = async (args: { request: Request }) => { - const app = await getApp(args); + const app = await getApp(); return app.fetch(args.request); }; diff --git a/examples/react-router/bknd.config.ts b/examples/react-router/bknd.config.ts new file mode 100644 index 00000000..4cfa7145 --- /dev/null +++ b/examples/react-router/bknd.config.ts @@ -0,0 +1,64 @@ +import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import type { ReactRouterBkndConfig } from "bknd/adapter/react-router"; +import { boolean, em, entity, text } from "bknd/data"; +import { secureRandomString } from "bknd/utils"; + +// since we're running in node, we can register the local media adapter +const local = registerLocalMediaAdapter(); + +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + +export default { + // we can use any libsql config, and if omitted, uses in-memory + app: (env) => ({ + connection: { + url: env?.DB_URL ?? "file:data.db", + }, + }), + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-remix-example", + secret: secureRandomString(64), + }, + }, + // ... and media + media: { + enabled: true, + adapter: local({ + path: "./public", + }), + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + // create some entries + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + + // and create a user + await ctx.app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + }, +} as const satisfies ReactRouterBkndConfig<{ DB_URL?: string }>; From 2f067451b45a1d536ad7a631093a893e296ba2b0 Mon Sep 17 00:00:00 2001 From: Cameron Pak Date: Tue, 1 Apr 2025 05:58:13 -0500 Subject: [PATCH 06/11] chore: update dependencies and enhance TypeScript configuration for Astro example (#128) --- examples/astro/package.json | 10 +++++----- examples/astro/tsconfig.json | 8 ++++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/examples/astro/package.json b/examples/astro/package.json index 74379593..f205b50f 100644 --- a/examples/astro/package.json +++ b/examples/astro/package.json @@ -11,13 +11,13 @@ }, "dependencies": { "@astrojs/check": "^0.9.4", - "@astrojs/react": "^3.6.3", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@astrojs/react": "^4.2.2", + "@types/react": "^19.0.12", + "@types/react-dom": "^19.0.4", "astro": "^4.16.16", "bknd": "file:../../app", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.1.0", + "react-dom": "^19.1.0", "typescript": "^5.7.2" } } diff --git a/examples/astro/tsconfig.json b/examples/astro/tsconfig.json index bcbf8b50..032ad645 100644 --- a/examples/astro/tsconfig.json +++ b/examples/astro/tsconfig.json @@ -1,3 +1,7 @@ { - "extends": "astro/tsconfigs/strict" -} + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} \ No newline at end of file From 9134d121cdd6da8eeb6e6904df3f2e6917557cfc Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 13:24:32 +0200 Subject: [PATCH 07/11] keep extension from file when generating random name (#127) * keep extension from file when generating random name * added test for random name generation --- app/__test__/media/mime-types.spec.ts | 5 +++++ app/src/media/utils/index.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) 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/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; } From 44b3f720051f49bc2e08717abeb5116b69d2629d Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 13:37:11 +0200 Subject: [PATCH 08/11] added media overlay preview fallback --- app/package.json | 2 +- app/src/ui/modals/media/MediaInfoModal.tsx | 16 ++++++++++++++-- app/vite.dev.ts | 2 +- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index 44c8dbde..0ddd5906 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.1", "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": { diff --git a/app/src/ui/modals/media/MediaInfoModal.tsx b/app/src/ui/modals/media/MediaInfoModal.tsx index 744ea606..43f0f1cb 100644 --- a/app/src/ui/modals/media/MediaInfoModal.tsx +++ b/app/src/ui/modals/media/MediaInfoModal.tsx @@ -44,8 +44,7 @@ export function MediaInfoModal({ return (
- {/* @ts-ignore */} - +
@@ -157,6 +156,19 @@ const Item = ({ ); }; +const FilePreview = ({ file }: { file: FileState }) => { + if (file.type.startsWith("image/") || file.type.startsWith("video/")) { + // @ts-ignore + return ; + } + + return ( +
+ No Preview Available +
+ ); +}; + MediaInfoModal.defaultTitle = undefined; MediaInfoModal.modalProps = { withCloseButton: false, diff --git a/app/vite.dev.ts b/app/vite.dev.ts index 6705b2b2..3b8a7aae 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -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/media/storage/adapters/StorageLocalAdapter"; +import { StorageLocalAdapter } from "./src/adapter/node"; import { EntityManager, LibsqlConnection } from "data"; import { __bknd } from "modules/ModuleManager"; From e4608b7df74fb12d1e58092ef84c538ed4a2185d Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 1 Apr 2025 13:49:58 +0200 Subject: [PATCH 09/11] cosmetics: fixed admin skeleton, use password field on auth, use $console in auth middleware --- app/__test__/api/DataApi.spec.ts | 12 +++++++++++- app/package.json | 4 ++-- app/src/auth/middlewares.ts | 6 +++--- app/src/ui/Admin.tsx | 8 ++++---- app/src/ui/elements/auth/AuthForm.tsx | 4 ++-- 5 files changed, 22 insertions(+), 12 deletions(-) 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/package.json b/app/package.json index 0ddd5906..c3d2f55c 100644 --- a/app/package.json +++ b/app/package.json @@ -3,7 +3,7 @@ "type": "module", "sideEffects": false, "bin": "./dist/cli/index.js", - "version": "0.11.0-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": { @@ -32,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" }, "license": "FSL-1.1-MIT", 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/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 }) => {
-