diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1030c9a..6fbec3c 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 0000000..734fb08 --- /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 9faf7ca..44c8dbd 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 b4e0ea5..d8d147a 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 0000000..0ddb2b7 --- /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 0000000..3f3d1e8 --- /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 61971e3..a684f73 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 9488065..f73b76e 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 0000000..e6873d8 --- /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 0000000..7423190 --- /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 2087c5e..44fb795 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 0000000..7bd314a --- /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 0000000..22449a4 --- /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 dec7d09..f1ac9e6 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 0000000..4b9f3d7 --- /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 78d8eb4..4791124 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 369d451..d09a006 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 2d34d88..b5730b1 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 4e3b95b..16c4fa5 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 6020d92..62c41f6 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 6d416e9..84fb8c5 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 0000000..8e3f2e4 --- /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 32ff102..2b3b829 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 e047040..c009c07 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 0000000..c4ece3b --- /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 0000000..4050c68 --- /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 97a8b82..816eb92 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 ea76d9c..b231c15 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 12b91ce..8a44f40 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 e2c3922..992cbee 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 0000000..25ef895 --- /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 7e796c6..4474509 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 2d8e11b..daa7587 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 22485ef..ca1ffba 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 f5cb0e8..cc3b5c3 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 207e030..455e6d4 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 a23744b..f0b1b52 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 0000000..ebe3f2a --- /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 0000000..2229784 --- /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 de6b15d..d672c47 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 16472ca..477270d 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 854df7f..56ae737 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 ca8f380..027f48f 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 9f66545..0f76fd8 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 1f7bc02..0d7fa09 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 a7d586b..4820804 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 0000000..a8cad87 --- /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 3375019..44f9e8f 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 65175f9..0000000 --- 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 5e489d1..b9b9e76 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 0000000..68d15b5 --- /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 24061f7..d38baa4 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 6819cad..0676bb1 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 d01a6de..ee80848 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 0000000..4cfa714 --- /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 }>;