Merge pull request #88 from bknd-io/refactor/improve-remix-example

refactor/improve-remix-example
This commit is contained in:
dswbx
2025-02-19 17:44:10 +01:00
committed by GitHub
28 changed files with 532 additions and 261 deletions

View File

@@ -56,7 +56,7 @@ async function buildApi() {
watch, watch,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
outDir: "dist", outDir: "dist",
external: ["bun:test", "@libsql/client"], external: ["bun:test", "@libsql/client", "bknd/client"],
metafile: true, metafile: true,
platform: "browser", platform: "browser",
format: ["esm"], format: ["esm"],

View File

@@ -49,13 +49,14 @@
"hono": "^4.6.12", "hono": "^4.6.12",
"json-schema-form-react": "^0.0.2", "json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7", "json-schema-library": "^10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"liquidjs": "^10.15.0", "liquidjs": "^10.15.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"object-path-immutable": "^4.1.2", "object-path-immutable": "^4.1.2",
"picocolors": "^1.1.1",
"radix-ui": "^1.1.2", "radix-ui": "^1.1.2",
"json-schema-to-ts": "^3.1.1",
"swr": "^2.2.5" "swr": "^2.2.5"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,4 +1,6 @@
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { Api, type ApiOptions } from "bknd/client";
import { $console } from "core";
import { Event } from "core/events"; import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono"; import type { Hono } from "hono";
@@ -69,17 +71,17 @@ export class App {
// respond to events, such as "onUpdated". // respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually // this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) { if (!this.emgr.enabled) {
console.warn("App config updated, but event manager is disabled, skip."); $console.warn("App config updated, but event manager is disabled, skip.");
return; return;
} }
console.log("App config updated", key); $console.log("App config updated", key);
// @todo: potentially double syncing // @todo: potentially double syncing
await this.build({ sync: true }); await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}, },
onFirstBoot: async () => { onFirstBoot: async () => {
console.log("App first boot"); $console.log("App first boot");
this.trigger_first_boot = true; this.trigger_first_boot = true;
}, },
onServerInit: async (server) => { onServerInit: async (server) => {
@@ -177,6 +179,15 @@ export class App {
async createUser(p: CreateUserPayload) { async createUser(p: CreateUserPayload) {
return this.module.auth.createUser(p); return this.module.auth.createUser(p);
} }
getApi(options: Request | ApiOptions = {}) {
const fetcher = this.server.request as typeof fetch;
if (options instanceof Request) {
return new Api({ request: options, headers: options.headers, fetcher });
}
return new Api({ host: "http://localhost", ...options, fetcher });
}
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {
@@ -187,7 +198,7 @@ export function createApp(config: CreateAppConfig = {}) {
connection = config.connection; connection = config.connection;
} else if (typeof config.connection === "object") { } else if (typeof config.connection === "object") {
if ("type" in config.connection) { if ("type" in config.connection) {
console.warn( $console.warn(
"Using deprecated connection type 'libsql', use the 'config' object directly." "Using deprecated connection type 'libsql', use the 'config' object directly."
); );
connection = new LibsqlConnection(config.connection.config); connection = new LibsqlConnection(config.connection.config);
@@ -196,10 +207,10 @@ export function createApp(config: CreateAppConfig = {}) {
} }
} else { } else {
connection = new LibsqlConnection({ url: ":memory:" }); connection = new LibsqlConnection({ url: ":memory:" });
console.warn("No connection provided, using in-memory database"); $console.warn("No connection provided, using in-memory database");
} }
} catch (e) { } catch (e) {
console.error("Could not create connection", e); $console.error("Could not create connection", e);
} }
if (!connection) { if (!connection) {

View File

@@ -1,6 +1,5 @@
import type { App } from "bknd"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>; export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
@@ -9,29 +8,30 @@ type RemixContext = {
}; };
let app: App; let app: App;
let building: boolean = false;
export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
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;
}
export function serve<Args extends RemixContext = RemixContext>( export function serve<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args> = {} config: RemixBkndConfig<Args> = {}
) { ) {
return async (args: Args) => { return async (args: Args) => {
if (!app) {
app = await createFrameworkApp(config, args); app = await createFrameworkApp(config, args);
}
return app.fetch(args.request); return app.fetch(args.request);
}; };
} }
export function withApi<Args extends { request: Request; context: { api: Api } }, R>(
handler: (args: Args, api: Api) => Promise<R>
) {
return async (args: Args) => {
if (!args.context.api) {
args.context.api = new Api({
host: new URL(args.request.url).origin,
headers: args.request.headers
});
await args.context.api.verifyAuth();
}
return handler(args, args.context.api);
};
}

View File

@@ -5,6 +5,7 @@ import type { CliCommand } from "cli/types";
import { typewriter, wait } from "cli/utils/cli"; import { typewriter, wait } from "cli/utils/cli";
import { execAsync, getVersion } from "cli/utils/sys"; import { execAsync, getVersion } from "cli/utils/sys";
import { Option } from "commander"; import { Option } from "commander";
import { colorizeConsole } from "core";
import color from "picocolors"; import color from "picocolors";
import { overridePackageJson, updateBkndPackages } from "./npm"; import { overridePackageJson, updateBkndPackages } from "./npm";
import { type Template, templates } from "./templates"; import { type Template, templates } from "./templates";
@@ -48,6 +49,7 @@ function errorOutro() {
async function action(options: { template?: string; dir?: string; integration?: string }) { async function action(options: { template?: string; dir?: string; integration?: string }) {
console.log(""); console.log("");
colorizeConsole(console);
const downloadOpts = { const downloadOpts = {
dir: options.dir || "./", dir: options.dir || "./",

View File

@@ -36,7 +36,6 @@ const subjects = {
}; };
async function action(subject: string) { async function action(subject: string) {
console.log("debug", { subject });
if (subject in subjects) { if (subject in subjects) {
await subjects[subject](); await subjects[subject]();
} else { } else {

View File

@@ -2,9 +2,8 @@ import type { Config } from "@libsql/client/node";
import { App, type CreateAppConfig } from "App"; import { App, type CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node"; import { StorageLocalAdapter } from "adapter/node";
import type { CliBkndConfig, CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { replaceConsole } from "cli/utils/cli";
import { Option } from "commander"; import { Option } from "commander";
import { config } from "core"; import { colorizeConsole, config } from "core";
import dotenv from "dotenv"; import dotenv from "dotenv";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import c from "picocolors"; import c from "picocolors";
@@ -112,7 +111,7 @@ async function action(options: {
dbToken?: string; dbToken?: string;
server: Platform; server: Platform;
}) { }) {
replaceConsole(); colorizeConsole(console);
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
let app: App | undefined = undefined; let app: App | undefined = undefined;

View File

@@ -1,6 +1,3 @@
import { isDebug } from "core";
import c from "picocolors";
import type { Formatter } from "picocolors/types";
const _SPEEDUP = process.env.LOCAL; const _SPEEDUP = process.env.LOCAL;
const DEFAULT_WAIT = _SPEEDUP ? 0 : 250; const DEFAULT_WAIT = _SPEEDUP ? 0 : 250;
@@ -57,31 +54,3 @@ export async function* typewriter(
} }
} }
} }
function ifString(args: any[], c: Formatter) {
return args.map((a) => (typeof a === "string" ? c(a) : a));
}
const originalConsole = {
log: console.log,
info: console.info,
debug: console.debug,
warn: console.warn,
error: console.error
};
export const $console = {
log: (...args: any[]) => originalConsole.info(c.gray("[LOG] "), ...ifString(args, c.dim)),
info: (...args: any[]) => originalConsole.info(c.cyan("[INFO] "), ...args),
debug: (...args: any[]) => isDebug() && originalConsole.info(c.yellow("[DEBUG]"), ...args),
warn: (...args: any[]) => originalConsole.info(c.yellow("[WARN] "), ...ifString(args, c.yellow)),
error: (...args: any[]) => originalConsole.info(c.red("[ERROR]"), ...ifString(args, c.red))
};
export function replaceConsole() {
console.log = $console.log;
console.info = $console.info;
console.debug = $console.debug;
console.warn = $console.warn;
console.error = $console.error;
}

105
app/src/core/console.ts Normal file
View File

@@ -0,0 +1,105 @@
import colors from "picocolors";
function hasColors() {
try {
// biome-ignore lint/style/useSingleVarDeclarator: <explanation>
const p = process || {},
argv = p.argv || [],
env = p.env || {};
return (
!(!!env.NO_COLOR || argv.includes("--no-color")) &&
// biome-ignore lint/complexity/useOptionalChain: <explanation>
(!!env.FORCE_COLOR ||
argv.includes("--color") ||
p.platform === "win32" ||
((p.stdout || {}).isTTY && env.TERM !== "dumb") ||
!!env.CI)
);
} catch (e) {
return false;
}
}
const originalConsoles = {
error: console.error,
warn: console.warn,
info: console.info,
log: console.log,
debug: console.debug
} as typeof console;
function __tty(type: any, args: any[]) {
const has = hasColors();
const styles = {
error: {
prefix: colors.red,
args: colors.red
},
warn: {
prefix: colors.yellow,
args: colors.yellow
},
info: {
prefix: colors.cyan
},
log: {
prefix: colors.gray
},
debug: {
prefix: colors.yellow
}
} as const;
const prefix = styles[type].prefix(
`[${type.toUpperCase()}]${has ? " ".repeat(5 - type.length) : ""}`
);
const _args = args.map((a) =>
"args" in styles[type] && has && typeof a === "string" ? styles[type].args(a) : a
);
return originalConsoles[type](prefix, ..._args);
}
export type TConsoleSeverity = keyof typeof originalConsoles;
const severities = Object.keys(originalConsoles) as TConsoleSeverity[];
let enabled = [...severities];
export function disableConsole(severities: TConsoleSeverity[] = enabled) {
enabled = enabled.filter((s) => !severities.includes(s));
}
export function enableConsole() {
enabled = [...severities];
}
export const $console = new Proxy(
{},
{
get: (_, prop) => {
if (prop in originalConsoles && enabled.includes(prop as TConsoleSeverity)) {
return (...args: any[]) => __tty(prop, args);
}
return () => null;
}
}
) as typeof console;
export async function withDisabledConsole<R>(
fn: () => Promise<R>,
sev?: TConsoleSeverity[]
): Promise<R> {
disableConsole(sev);
try {
const result = await fn();
enableConsole();
return result;
} catch (e) {
enableConsole();
throw e;
}
}
export function colorizeConsole(con: typeof console) {
for (const [key] of Object.entries(originalConsoles)) {
con[key] = $console[key];
}
}

View File

@@ -26,6 +26,8 @@ export {
} from "./object/query/query"; } from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry"; export { Registry, type Constructor } from "./registry/Registry";
export * from "./console";
// compatibility // compatibility
export type Middleware = MiddlewareHandler<any, any, any>; export type Middleware = MiddlewareHandler<any, any, any>;
export interface ClassController { export interface ClassController {

View File

@@ -28,7 +28,7 @@ export class LibsqlConnection extends SqliteConnection {
constructor(clientOrCredentials: Client | LibSqlCredentials) { constructor(clientOrCredentials: Client | LibSqlCredentials) {
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()]; const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
let client: Client; let client: Client;
if ("url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials; let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) { if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
console.log("changing protocol to", protocol); console.log("changing protocol to", protocol);
@@ -36,11 +36,8 @@ export class LibsqlConnection extends SqliteConnection {
url = `${protocol}://${rest}`; url = `${protocol}://${rest}`;
} }
//console.log("using", url, { protocol });
client = createClient({ url, authToken }); client = createClient({ url, authToken });
} else { } else {
//console.log("-- client provided");
client = clientOrCredentials; client = clientOrCredentials;
} }
@@ -48,7 +45,6 @@ export class LibsqlConnection extends SqliteConnection {
// @ts-expect-error libsql has type issues // @ts-expect-error libsql has type issues
dialect: new CustomLibsqlDialect({ client }), dialect: new CustomLibsqlDialect({ client }),
plugins plugins
//log: ["query"],
}); });
super(kysely, {}, plugins); super(kysely, {}, plugins);
@@ -90,7 +86,6 @@ export class LibsqlConnection extends SqliteConnection {
const rows = await kyselyPlugins.transformResultRows(r.rows); const rows = await kyselyPlugins.transformResultRows(r.rows);
data.push(rows); data.push(rows);
} }
//console.log("data", data);
return data; return data;
} }

View File

@@ -1,4 +1,5 @@
import type { DB as DefaultDB, PrimaryFieldType } from "core"; import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { $console } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
@@ -161,7 +162,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> { protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity; const entity = this.entity;
const compiled = qb.compile(); const compiled = qb.compile();
//console.log("performQuery", compiled.sql, compiled.parameters); //$console.log("performQuery", compiled.sql, compiled.parameters);
const start = performance.now(); const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as); const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
@@ -180,7 +181,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
totalQuery, totalQuery,
qb qb
]); ]);
//console.log("result", { _count, _total }); //$console.log("result", { _count, _total });
const time = Number.parseFloat((performance.now() - start).toFixed(2)); const time = Number.parseFloat((performance.now() - start).toFixed(2));
const data = this.em.hydrate(entity.name, result); const data = this.em.hydrate(entity.name, result);
@@ -201,7 +202,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}; };
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
console.error("[ERROR] Repository.performQuery", e.message); $console.error("[ERROR] Repository.performQuery", e.message);
} }
throw e; throw e;
@@ -254,7 +255,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
...config?.defaults ...config?.defaults
}; };
/*console.log("build query options", { /*$console.log("build query options", {
entity: entity.name, entity: entity.name,
options, options,
config config
@@ -426,9 +427,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
qb = qb.limit(1); qb = qb.limit(1);
const compiled = qb.compile(); const compiled = qb.compile();
//console.log("exists query", compiled.sql, compiled.parameters); //$console.log("exists query", compiled.sql, compiled.parameters);
const result = await qb.execute(); const result = await qb.execute();
//console.log("result", result); //$console.log("result", result);
return { return {
sql: compiled.sql, sql: compiled.sql,

View File

@@ -1,5 +1,5 @@
import { Guard } from "auth"; import { Guard } from "auth";
import { BkndError, DebugLogger } from "core"; import { BkndError, DebugLogger, withDisabledConsole } from "core";
import { EventManager } from "core/events"; import { EventManager } from "core/events";
import { clone, diff } from "core/object/diff"; import { clone, diff } from "core/object/diff";
import { import {
@@ -10,8 +10,7 @@ import {
mark, mark,
objectEach, objectEach,
stripMark, stripMark,
transformObject, transformObject
withDisabledConsole
} from "core/utils"; } from "core/utils";
import { import {
type Connection, type Connection,

View File

@@ -17,6 +17,7 @@ export type AdminControllerOptions = {
assets_path?: string; assets_path?: string;
html?: string; html?: string;
forceDev?: boolean | { mainPath: string }; forceDev?: boolean | { mainPath: string };
debug_rerenders?: boolean;
}; };
export class AdminController extends Controller { export class AdminController extends Controller {
@@ -163,17 +164,13 @@ export class AdminController extends Controller {
}; };
if (isProd) { if (isProd) {
try {
// @ts-ignore // @ts-ignore
const manifest = await import("bknd/dist/manifest.json", { const manifest = await import("bknd/dist/manifest.json", {
assert: { type: "json" } assert: { type: "json" }
}).then((m) => m.default); });
// @todo: load all marked as entry (incl. css) // @todo: load all marked as entry (incl. css)
assets.js = manifest["src/ui/main.tsx"].file; assets.js = manifest.default["src/ui/main.tsx"].file;
assets.css = manifest["src/ui/main.tsx"].css[0] as any; assets.css = manifest.default["src/ui/main.tsx"].css[0] as any;
} catch (e) {
console.error("Error loading manifest", e);
}
} }
const theme = configs.server.admin.color_scheme ?? "light"; const theme = configs.server.admin.color_scheme ?? "light";
@@ -192,10 +189,12 @@ export class AdminController extends Controller {
/> />
<link rel="icon" href={favicon} type="image/x-icon" /> <link rel="icon" href={favicon} type="image/x-icon" />
<title>BKND</title> <title>BKND</title>
{/*<script {this.options.debug_rerenders && (
<script
crossOrigin="anonymous" crossOrigin="anonymous"
src="//unpkg.com/react-scan/dist/auto.global.js" src="//unpkg.com/react-scan/dist/auto.global.js"
/>*/} />
)}
{isProd ? ( {isProd ? (
<Fragment> <Fragment>
<script <script

View File

@@ -25,11 +25,11 @@ export const ClientProvider = ({ children, baseUrl, user }: ClientProviderProps)
console.warn("wrapped many times, take from context", actualBaseUrl); console.warn("wrapped many times, take from context", actualBaseUrl);
} else if (typeof window !== "undefined") { } else if (typeof window !== "undefined") {
actualBaseUrl = window.location.origin; actualBaseUrl = window.location.origin;
console.log("setting from window", actualBaseUrl); //console.log("setting from window", actualBaseUrl);
} }
} }
} catch (e) { } catch (e) {
console.error("error .....", e); console.error("Error in ClientProvider", e);
} }
//console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user }); //console.log("api init", { host: actualBaseUrl, user: user ?? winCtx.user });

BIN
bun.lockb

Binary file not shown.

View File

@@ -9,28 +9,52 @@ Install bknd as a dependency:
<InstallBknd /> <InstallBknd />
## Serve the API ## Serve the API
Create a new api splat route file at `app/routes/api.$.ts`: Since Remix doesn't support middleware yet, we need a helper file to initialize the App to import from. Create a new file at `app/bknd.ts`:
```ts ```ts app/bknd.ts
// app/routes/api.$.ts import { type RemixBkndConfig, getApp as getBkndApp } from "bknd/adapter/remix";
import { serve } from "bknd/adapter/remix";
const handler = serve({ const config = {
connection: { connection: {
url: "http://localhost:8080" url: "file:data.db"
} }
}); } as const satisfies RemixBkndConfig;
export async function getApp(args?: { request: Request }) {
return await getBkndApp(config, args);
}
export async function getApi(args?: { request: Request }) {
const app = await getApp(args);
if (args) {
const api = app.getApi(args.request);
await api.verifyAuth();
return api;
}
return app.getApi();
}
```
Create a new api splat route file at `app/routes/api.$.ts`:
```ts app/routes/api.$.ts
import { getApp } from "~/bknd";
const handler = async (args: { request: Request }) => {
const app = await getApp(args);
return app.fetch(args.request);
};
export const loader = handler; export const loader = handler;
export const action = handler; export const action = handler;
``` ```
For more information about the connection object, refer to the [Database](/usage/database) guide. For more information about the connection object, refer to the [Database](/usage/database) guide.
Now make sure that you wrap your root layout with the `ClientProvider` so that all components Now make sure that you wrap your root layout with the `ClientProvider` so that all components share the same context. Also add the user context to both the `Outlet` and the provider:
share the same context: ```tsx app/root.tsx
```tsx import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
// app/root.tsx import { useLoaderData, Outlet } from "@remix-run/react";
import { withApi } from "bknd/adapter/remix" import { ClientProvider } from "bknd/client";
import { type Api, ClientProvider } from "bknd/client"; import { getApi } from "~/bknd";
export function Layout(props) { export function Layout(props) {
// nothing to change here, just for orientation // nothing to change here, just for orientation
@@ -39,27 +63,18 @@ export function Layout(props) {
); );
} }
// add the api to the `AppLoadContext` export const loader = async (args: LoaderFunctionArgs) => {
// so you don't have to manually type it again const api = await getApi(args);
declare module "@remix-run/server-runtime" {
export interface AppLoadContext {
api: Api;
}
}
// export a loader that initiates the API
// and passes it down to args.context.api
export const loader = withApi(async (args: LoaderFunctionArgs, api: Api) => {
return { return {
user: api.getUser() user: api.getUser()
}; };
}); };
export default function App() { export default function App() {
const { user } = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
return ( return (
<ClientProvider user={user}> <ClientProvider user={data.user}>
<Outlet /> <Outlet context={data} />
</ClientProvider> </ClientProvider>
); );
} }
@@ -67,24 +82,29 @@ export default function App() {
## Enabling the Admin UI ## Enabling the Admin UI
Create a new splat route file at `app/routes/admin.$.tsx`: Create a new splat route file at `app/routes/admin.$.tsx`:
```tsx ```tsx app/routes/admin.$.tsx
// app/routes/admin.$.tsx
import { adminPage } from "bknd/adapter/remix"; import { adminPage } from "bknd/adapter/remix";
import "bknd/dist/styles.css"; import "bknd/dist/styles.css";
export default adminPage({ export default adminPage({
config: { basepath: "/admin" } config: {
basepath: "/admin",
logo_return_path: "/../",
color_scheme: "system"
}
}); });
``` ```
## Example usage of the API ## Example usage of the API
Since the API has already been constructed in the root layout, you can now use it in any page: Since the API has already been constructed in the root layout, you can now use it in any page:
```tsx ```tsx app/routes/_index.tsx
// app/routes/_index.tsx
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useLoaderData } from "@remix-run/react"; import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { getApi } from "~/bknd";
export const loader = async ({ context: { api } }: LoaderFunctionArgs) => { export const loader = async (args: LoaderFunctionArgs) => {
// use authentication from request
const api = await getApi(args);
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
return { data, user: api.getUser() }; return { data, user: api.getUser() };
}; };

View File

@@ -0,0 +1,92 @@
import { App } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { type RemixBkndConfig, getApp as getBkndApp } from "bknd/adapter/remix";
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
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: "ds@bknd.io",
password: "12345678"
});
},
"sync"
);
}
} as const satisfies RemixBkndConfig;
export async function getApp(args?: { request: Request }) {
return await getBkndApp(config, args);
}
/**
* If args are given, it will use authentication details from the request
* @param args
*/
export async function getApi(args?: { request: Request }) {
const app = await getApp(args);
if (args) {
const api = app.getApi(args.request);
await api.verifyAuth();
return api;
}
return app.getApi();
}

View File

@@ -0,0 +1,9 @@
export function Check({ checked = false }: { checked?: boolean }) {
return (
<div
className={`aspect-square w-6 leading-none rounded-full p-px transition-colors cursor-pointer ${checked ? "bg-green-500" : "bg-white/20 hover:bg-white/40"}`}
>
<input type="checkbox" checked={checked} readOnly />
</div>
);
}

View File

@@ -1,7 +1,8 @@
import type { LoaderFunctionArgs } from "@remix-run/node"; import type { LoaderFunctionArgs } from "@remix-run/node";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react"; import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
import { withApi } from "bknd/adapter/remix"; import { ClientProvider } from "bknd/client";
import { type Api, ClientProvider } from "bknd/client"; import "./tailwind.css";
import { getApi } from "~/bknd";
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
@@ -21,26 +22,18 @@ export function Layout({ children }: { children: React.ReactNode }) {
); );
} }
declare module "@remix-run/server-runtime" { export const loader = async (args: LoaderFunctionArgs) => {
export interface AppLoadContext { const api = await getApi(args);
api: Api;
}
}
export const loader = withApi(async (args: LoaderFunctionArgs, api: Api) => {
return { return {
user: api.getUser() user: api.getUser()
}; };
}); };
export default function App() { export default function App() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
// add user to the client provider to indicate
// that you're authed using cookie
return ( return (
<ClientProvider user={data.user}> <ClientProvider user={data.user}>
<Outlet /> <Outlet context={data} />
</ClientProvider> </ClientProvider>
); );
} }

View File

@@ -1,24 +1,151 @@
import { type MetaFunction, useLoaderData } from "@remix-run/react"; import { type MetaFunction, useFetcher, useLoaderData, useOutletContext } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { getApi } from "~/bknd";
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [{ title: "Remix & bknd" }, { name: "description", content: "Welcome to Remix & bknd!" }]; return [
{ title: "New bknd-Remix App" },
{ name: "description", content: "Welcome to bknd & Remix!" }
];
}; };
export const loader = async ({ context: { api } }: LoaderFunctionArgs) => { export const loader = async () => {
const { data } = await api.data.readMany("todos"); const api = await getApi();
return { data, user: api.getUser() };
const limit = 5;
const {
data: todos,
body: { meta }
} = await api.data.readMany("todos", {
limit,
sort: "-id"
});
return { todos: todos.reverse(), total: meta.total, limit };
};
export const action = async (args: ActionFunctionArgs) => {
const api = await getApi();
const formData = await args.request.formData();
const action = formData.get("action") as string;
switch (action) {
case "update": {
const id = Number(formData.get("id"));
const done = formData.get("done") === "on";
if (id > 0) {
await api.data.updateOne("todos", id, { done });
}
break;
}
case "add": {
const title = formData.get("title") as string;
if (title.length > 0) {
await api.data.createOne("todos", { title });
}
break;
}
case "delete": {
const id = Number(formData.get("id"));
if (id > 0) {
await api.data.deleteOne("todos", id);
}
break;
}
}
return null;
}; };
export default function Index() { export default function Index() {
const { data, user } = useLoaderData<typeof loader>(); const ctx = useOutletContext<any>();
const { todos, total, limit } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
return ( return (
<div> <div className="flex h-screen items-center justify-center">
<h1>Data</h1> <div className="flex flex-col items-center gap-16">
<pre>{JSON.stringify(data, null, 2)}</pre> <header className="flex flex-col items-center gap-9">
<h1>User</h1> <h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
<pre>{JSON.stringify(user, null, 2)}</pre> bknd w/ <span className="sr-only">Remix</span>
</h1>
<div className="h-[144px] w-[434px]">
<img src="/logo-light.png" alt="Remix" className="block w-full dark:hidden" />
<img src="/logo-dark.png" alt="Remix" className="hidden w-full dark:block" />
</div>
</header>
<nav className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-gray-200 p-6 dark:border-gray-700">
<p className="leading-6 text-gray-700 dark:text-gray-200 font-bold">
What&apos;s next? ({total})
</p>
<div className="flex flex-col w-full gap-2">
{total > limit && (
<div className="bg-white/10 flex justify-center p-1 text-xs rounded text-gray-500">
{total - limit} more todo(s) hidden
</div>
)}
{todos.map((todo) => (
<div className="flex flex-row" key={String(todo.id)}>
<fetcher.Form
className="flex flex-row flex-grow items-center gap-3 ml-1"
method="post"
>
<input
type="checkbox"
name="done"
defaultChecked={todo.done}
onChange={(e) => fetcher.submit(e.currentTarget.form!)}
/>
<input type="hidden" name="action" value="update" />
<input type="hidden" name="id" value={String(todo.id)} />
<div className="dark:text-gray-300 text-gray-800">{todo.title}</div>
</fetcher.Form>
<fetcher.Form className="flex items-center" method="post">
<input type="hidden" name="action" value="delete" />
<input type="hidden" name="id" value={String(todo.id)} />
<button
type="submit"
className="cursor-pointer grayscale transition-all hover:grayscale-0 text-xs "
>
</button>
</fetcher.Form>
</div>
))}
<fetcher.Form
className="flex flex-row gap-3 mt-2"
method="post"
key={todos.map((t) => t.id).join()}
>
<input
type="text"
name="title"
placeholder="New todo"
className="py-2 px-4 rounded-xl bg-black/5 dark:bg-white/10"
/>
<input type="hidden" name="action" value="add" />
<button type="submit" className="cursor-pointer">
Add
</button>
</fetcher.Form>
</div>
</nav>
<div className="flex flex-col items-center gap-4">
<a href="/admin">Go to Admin </a>
<div className="opacity-50 text-xs">
{ctx.user ? (
<p>
Authenticated as <b>{ctx.user.email}</b>
</p>
) : (
<a href="/admin/auth/login">Login</a>
)}
</div>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import "bknd/dist/styles.css";
export default adminPage({ export default adminPage({
config: { config: {
basepath: "/admin", basepath: "/admin",
logo_return_path: "/../" logo_return_path: "/../",
color_scheme: "system"
} }
}); });

View File

@@ -1,76 +1,9 @@
import { App } from "bknd"; import { getApp } from "~/bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { serve } from "bknd/adapter/remix";
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 handler = async (args: { request: Request }) => {
registerLocalMediaAdapter(); const app = await getApp(args);
return app.fetch(args.request);
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 handler = serve({
// 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: "ds@bknd.io",
password: "12345678"
});
},
"sync"
);
}
});
export const loader = handler; export const loader = handler;
export const action = handler; export const action = handler;

View File

@@ -0,0 +1,10 @@
@import "tailwindcss";
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}

View File

@@ -20,6 +20,8 @@
"remix-utils": "^7.0.0" "remix-utils": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"tailwindcss": "^4.0.7",
"@remix-run/dev": "^2.15.2", "@remix-run/dev": "^2.15.2",
"@types/react": "^18.2.20", "@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,4 +1,5 @@
import { vitePlugin as remix } from "@remix-run/dev"; import { vitePlugin as remix } from "@remix-run/dev";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
@@ -10,6 +11,7 @@ declare module "@remix-run/node" {
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
tailwindcss(),
remix({ remix({
future: { future: {
v3_fetcherPersist: true, v3_fetcherPersist: true,