Merge pull request #89 from bknd-io/feat/app-api-exp-for-nextjs

optimized local api instantiation to prepare for nextjs app router
This commit is contained in:
dswbx
2025-03-03 07:16:01 +01:00
committed by GitHub
44 changed files with 403 additions and 337 deletions

View File

@@ -48,7 +48,7 @@ describe("App", () => {
const todos = await app.getApi().data.readMany("todos");
expect(todos.length).toBe(2);
expect(todos[0].title).toBe("ctx");
expect(todos[1].title).toBe("api");
expect(todos[0]?.title).toBe("ctx");
expect(todos[1]?.title).toBe("api");
});
});

View File

@@ -56,7 +56,7 @@ async function buildApi() {
watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist",
external: ["bun:test", "@libsql/client", "bknd/client"],
external: ["bun:test", "@libsql/client"],
metafile: true,
platform: "browser",
format: ["esm"],
@@ -71,16 +71,19 @@ async function buildApi() {
});
}
async function rewriteClient(path: string) {
const bundle = await Bun.file(path).text();
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
}
/**
* Building UI for direct imports
*/
async function buildUi() {
await tsup.build({
const base = {
minify,
sourcemap,
watch,
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
external: [
"bun:test",
"react",
@@ -104,7 +107,24 @@ async function buildUi() {
esbuildOptions: (options) => {
options.logLevel = "silent";
},
} satisfies tsup.Options;
await tsup.build({
...base,
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
outDir: "dist/ui",
onSuccess: async () => {
await rewriteClient("./dist/ui/index.js");
delayTypes();
},
});
await tsup.build({
...base,
entry: ["src/ui/client/index.ts"],
outDir: "dist/ui/client",
onSuccess: async () => {
await rewriteClient("./dist/ui/client/index.js");
delayTypes();
},
});
@@ -146,11 +166,7 @@ async function buildUiElements() {
};
},
onSuccess: async () => {
// manually replace ui/client with bknd/client
const path = "./dist/ui/elements/index.js";
const bundle = await Bun.file(path).text();
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
await rewriteClient("./dist/ui/elements/index.js");
delayTypes();
},
});

View File

@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.9.0-rc.1",
"version": "0.9.0-rc.1-7",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {

View File

@@ -50,6 +50,7 @@ export type CreateAppConfig = {
};
export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions;
export class App {
modules: ModuleManager;
@@ -186,13 +187,13 @@ export class App {
return this.module.auth.createUser(p);
}
getApi(options: Request | ApiOptions = {}) {
getApi(options?: LocalApiOptions) {
const fetcher = this.server.request as typeof fetch;
if (options instanceof Request) {
if (options && options instanceof Request) {
return new Api({ request: options, headers: options.headers, fetcher });
}
return new Api({ host: "http://localhost", ...options, fetcher });
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
}
}

View File

@@ -1,49 +1,35 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { nodeRequestToRequest } from "adapter/utils";
import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[];
cleanRequest?: { searchParams?: string[] };
};
type GetServerSidePropsContext = {
req: IncomingMessage;
res: ServerResponse;
params?: Params;
query: any;
preview?: boolean;
previewData?: any;
draftMode?: boolean;
resolvedUrl: string;
locale?: string;
locales?: string[];
defaultLocale?: string;
};
let app: App;
let building: boolean = false;
export function createApi({ req }: GetServerSidePropsContext) {
const request = nodeRequestToRequest(req);
return new Api({
host: new URL(request.url).origin,
headers: request.headers,
});
export async function getApp(config: NextjsBkndConfig) {
if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
if (app) return app;
}
building = true;
if (!app) {
app = await createFrameworkApp(config);
await app.build();
}
building = false;
return app;
}
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
return async (ctx: GetServerSidePropsContext & { api: Api }) => {
const api = createApi(ctx);
await api.verifyAuth();
return handler({ ...ctx, api });
};
}
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
if (!cleanRequest) return req;
function getCleanRequest(
req: Request,
{ cleanSearch = ["route"] }: Pick<NextjsBkndConfig, "cleanSearch">,
) {
const url = new URL(req.url);
cleanSearch?.forEach((k) => url.searchParams.delete(k));
cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
return new Request(url.toString(), {
method: req.method,
@@ -52,13 +38,12 @@ function getCleanRequest(
});
}
let app: App;
export function serve({ cleanSearch, ...config }: NextjsBkndConfig = {}) {
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
return async (req: Request) => {
if (!app) {
app = await createFrameworkApp(config);
app = await getApp(config);
}
const request = getCleanRequest(req, { cleanSearch });
return app.fetch(request, process.env);
const request = getCleanRequest(req, cleanRequest);
return app.fetch(request);
};
}

View File

@@ -10,7 +10,10 @@ type RemixContext = {
let app: App;
let building: boolean = false;
export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
export async function getApp<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args>,
args?: Args
) {
if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
@@ -31,7 +34,7 @@ export function serve<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args> = {},
) {
return async (args: Args) => {
app = await createFrameworkApp(config, args);
app = await getApp(config, args);
return app.fetch(args.request);
};
}

View File

@@ -68,7 +68,7 @@ export async function replacePackageJsonVersions(
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
const versions = {
bknd: "^" + (await sysGetVersion()),
bknd: await sysGetVersion(),
...(map ?? {}),
};
await replacePackageJsonVersions(

View File

@@ -5,6 +5,7 @@ export {
type AppConfig,
type CreateAppConfig,
type AppPlugin,
type LocalApiOptions,
} from "./App";
export {

View File

@@ -264,7 +264,6 @@ export class FetchPromise<T = ApiResponse<any>> implements Promise<T> {
} else {
resBody = res.body;
}
console.groupEnd();
return createResponseProxy<T>(res, resBody, resData);
}

View File

@@ -286,7 +286,7 @@ export class ModuleManager {
return result as unknown as ConfigTable;
},
this.verbosity > Verbosity.silent ? [] : ["log", "error", "warn"],
this.verbosity > Verbosity.silent ? [] : ["error"],
);
this.logger

View File

@@ -54,9 +54,9 @@ function AdminInternal() {
);
}
const Skeleton = ({ theme }: { theme?: string }) => {
const actualTheme =
(theme ?? document.querySelector("html")?.classList.contains("light")) ? "light" : "dark";
const Skeleton = ({ theme }: { theme?: any }) => {
const t = useTheme();
const actualTheme = theme ?? t.theme;
return (
<div id="bknd-admin" className={actualTheme + " antialiased"}>

View File

@@ -2,7 +2,7 @@ import type { AppTheme } from "modules/server/AppServer";
import { useBkndWindowContext } from "ui/client/ClientProvider";
import { useBknd } from "ui/client/bknd";
export function useTheme(fallback: AppTheme = "system"): { theme: AppTheme } {
export function useTheme(fallback: AppTheme = "system") {
const b = useBknd();
const winCtx = useBkndWindowContext();
@@ -14,13 +14,16 @@ export function useTheme(fallback: AppTheme = "system"): { theme: AppTheme } {
const override = b?.adminOverride?.color_scheme;
const config = b?.config.server.admin.color_scheme;
const win = winCtx.color_scheme;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const prefersDark =
typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = override ?? config ?? win ?? fallback;
if (theme === "system") {
return { theme: prefersDark ? "dark" : "light" };
}
return { theme };
return {
theme: (theme === "system" ? (prefersDark ? "dark" : "light") : theme) as AppTheme,
prefersDark,
override,
config,
win,
};
}

View File

@@ -4,15 +4,15 @@
// there is no lifecycle or Hook in React that we can use to switch
// .current at the right timing."
// So we will have to make do with this "close enough" approach for now.
import { useInsertionEffect, useRef } from "react";
import { useEffect, useRef } from "react";
export const useEvent = <Fn>(fn: Fn | ((...args: any[]) => any) | undefined): Fn => {
const ref = useRef([fn, (...args) => ref[0](...args)]).current;
// Per Dan Abramov: useInsertionEffect executes marginally closer to the
// correct timing for ref synchronization than useLayoutEffect on React 18.
// See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360
useInsertionEffect(() => {
useEffect(() => {
ref[0] = fn;
});
}, []);
return ref[1];
};

View File

@@ -45,7 +45,6 @@ export function StepEntityFields() {
const values = watch();
const updateListener = useEvent((data: TAppDataEntityFields) => {
console.log("updateListener", data);
setValue("fields", data as any);
});

View File

@@ -2,7 +2,7 @@ import { Type } from "core/utils";
import { type Entity, querySchema } from "data";
import { Fragment } from "react";
import { TbDots } from "react-icons/tb";
import { useApi, useApiQuery } from "ui/client";
import { useApiQuery } from "ui/client";
import { useBknd } from "ui/client/bknd";
import { useBkndData } from "ui/client/schema/data/use-bknd-data";
import { Button } from "ui/components/buttons/Button";
@@ -83,7 +83,7 @@ export function DataEntityList({ params }) {
search.set("perPage", perPage);
}
const isUpdating = $q.isLoading && $q.isValidating;
const isUpdating = $q.isLoading || $q.isValidating;
return (
<Fragment key={entity.name}>

View File

@@ -108,9 +108,7 @@ export const EntityFieldsForm = forwardRef<EntityFieldsFormRef, EntityFieldsForm
useEffect(() => {
if (props?.onChange) {
console.log("----set");
watch((data: any) => {
console.log("---calling");
props?.onChange?.(toCleanValues(data));
});
}