mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad7926db4c | |||
| 53a3dcdee7 | |||
| 53467d6750 | |||
| a80a731498 | |||
| 7e1757b7f4 | |||
| 2c29e06fb8 | |||
| ca86fa58ac | |||
| de984fa101 | |||
| a12d4e13d0 | |||
| fa6c7acaf5 |
+2
-1
@@ -1,3 +1,4 @@
|
||||
playwright-report
|
||||
test-results
|
||||
bknd.config.*
|
||||
bknd.config.*
|
||||
__test__/helper.d.ts
|
||||
+2
-1
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.11.0-rc.2",
|
||||
"version": "0.11.0",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -38,6 +38,7 @@
|
||||
"test:vitest:watch": "vitest",
|
||||
"test:vitest:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
"test:e2e:report": "playwright show-report"
|
||||
|
||||
+4
-1
@@ -180,7 +180,10 @@ export class App {
|
||||
registerAdminController(config?: AdminControllerOptions) {
|
||||
// register admin
|
||||
this.adminController = new AdminController(this, config);
|
||||
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
|
||||
this.modules.server.route(
|
||||
this.adminController.basepath,
|
||||
this.adminController.getController(),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||
case "url":
|
||||
additional.adminOptions = {
|
||||
...(typeof adminOptions === "object" ? adminOptions : {}),
|
||||
assets_path: assets.url,
|
||||
assetsPath: assets.url,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import type { FrameworkBkndConfig } from "bknd/adapter";
|
||||
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||
import { Hono } from "hono";
|
||||
import { serveStatic } from "hono/cloudflare-workers";
|
||||
import { getFresh } from "./modes/fresh";
|
||||
import { getCached } from "./modes/cached";
|
||||
import { getDurable } from "./modes/durable";
|
||||
import { getFresh, getWarm } from "./modes/fresh";
|
||||
import type { App } from "bknd";
|
||||
|
||||
export type CloudflareEnv = object;
|
||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env> & {
|
||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (args: Env) => {
|
||||
kv?: KVNamespace;
|
||||
@@ -20,8 +21,6 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = FrameworkBkndConfig<Env>
|
||||
keepAliveSeconds?: number;
|
||||
forceHttps?: boolean;
|
||||
manifest?: string;
|
||||
setAdminHtml?: boolean;
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export type Context<Env = CloudflareEnv> = {
|
||||
@@ -43,7 +42,7 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
throw new Error("manifest is required with static 'kv'");
|
||||
}
|
||||
|
||||
if (config.manifest && config.static !== "assets") {
|
||||
if (config.manifest && config.static === "kv") {
|
||||
const pathname = url.pathname.slice(1);
|
||||
const assetManifest = JSON.parse(config.manifest);
|
||||
if (pathname && pathname in assetManifest) {
|
||||
@@ -70,18 +69,24 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
const context = { request, env, ctx } as Context<Env>;
|
||||
const mode = config.mode ?? "warm";
|
||||
|
||||
let app: App;
|
||||
switch (mode) {
|
||||
case "fresh":
|
||||
return await getFresh(config, context);
|
||||
app = await getFresh(config, context, { force: true });
|
||||
break;
|
||||
case "warm":
|
||||
return await getWarm(config, context);
|
||||
app = await getFresh(config, context);
|
||||
break;
|
||||
case "cache":
|
||||
return await getCached(config, context);
|
||||
app = await getCached(config, context);
|
||||
break;
|
||||
case "durable":
|
||||
return await getDurable(config, context);
|
||||
default:
|
||||
throw new Error(`Unknown mode ${mode}`);
|
||||
}
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||
export { makeApp, getFresh } from "./modes/fresh";
|
||||
export { getCached } from "./modes/cached";
|
||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||
export { D1Connection, type D1ConnectionConfig };
|
||||
|
||||
@@ -40,7 +40,6 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
);
|
||||
await config.beforeBuild?.(app);
|
||||
},
|
||||
adminOptions: { html: config.html },
|
||||
},
|
||||
{ env, ctx, ...args },
|
||||
);
|
||||
|
||||
@@ -25,9 +25,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
|
||||
const res = await stub.fire(ctx.request, {
|
||||
config: create_config,
|
||||
html: config.html,
|
||||
keepAliveSeconds: config.keepAliveSeconds,
|
||||
setAdminHtml: config.setAdminHtml,
|
||||
});
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
@@ -110,6 +108,7 @@ export class DurableBkndApp extends DurableObject {
|
||||
}
|
||||
|
||||
async onBuilt(app: App) {}
|
||||
|
||||
async beforeBuild(app: App) {}
|
||||
|
||||
protected keepAlive(seconds: number) {
|
||||
|
||||
@@ -7,22 +7,15 @@ export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
args: Env = {} as Env,
|
||||
opts?: RuntimeOptions,
|
||||
) {
|
||||
return await createRuntimeApp<Env>(
|
||||
{
|
||||
...makeConfig(config, args),
|
||||
adminOptions: config.html ? { html: config.html } : undefined,
|
||||
},
|
||||
args,
|
||||
opts,
|
||||
);
|
||||
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
|
||||
}
|
||||
|
||||
export async function getWarm<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
ctx: Context<Env>,
|
||||
opts: RuntimeOptions = {},
|
||||
) {
|
||||
const app = await makeApp(
|
||||
return await makeApp(
|
||||
{
|
||||
...config,
|
||||
onBuilt: async (app) => {
|
||||
@@ -33,16 +26,4 @@ export async function getWarm<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
ctx.env,
|
||||
opts,
|
||||
);
|
||||
return app.fetch(ctx.request);
|
||||
}
|
||||
|
||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||
config: CloudflareBkndConfig<Env>,
|
||||
ctx: Context<Env>,
|
||||
opts: RuntimeOptions = {},
|
||||
) {
|
||||
return await getWarm(config, ctx, {
|
||||
...opts,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||
import {
|
||||
type DevServerOptions,
|
||||
default as honoViteDevServer,
|
||||
} from "@hono/vite-dev-server";
|
||||
import type { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import {
|
||||
type RuntimeBkndConfig,
|
||||
createRuntimeApp,
|
||||
type FrameworkOptions,
|
||||
} from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { devServerConfig } from "./dev-server-config";
|
||||
|
||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||
mode?: "cached" | "fresh";
|
||||
setAdminHtml?: boolean;
|
||||
forceDev?: boolean | { mainPath: string };
|
||||
html?: string;
|
||||
};
|
||||
export type ViteEnv = NodeJS.ProcessEnv;
|
||||
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
|
||||
|
||||
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
||||
export function addViteScript(
|
||||
html: string,
|
||||
addBkndContext: boolean = true,
|
||||
) {
|
||||
return html.replace(
|
||||
"</head>",
|
||||
`<script type="module">
|
||||
@@ -28,52 +34,40 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
);
|
||||
}
|
||||
|
||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||
async function createApp<ViteEnv>(
|
||||
config: ViteBkndConfig<ViteEnv> = {},
|
||||
env: ViteEnv = {} as ViteEnv,
|
||||
opts: FrameworkOptions = {},
|
||||
): Promise<App> {
|
||||
registerLocalMediaAdapter();
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
adminOptions:
|
||||
config.setAdminHtml === false
|
||||
? undefined
|
||||
: {
|
||||
html: config.html,
|
||||
forceDev: config.forceDev ?? {
|
||||
mainPath: "/src/main.tsx",
|
||||
},
|
||||
},
|
||||
adminOptions: config.adminOptions ?? {
|
||||
forceDev: {
|
||||
mainPath: "/src/main.tsx",
|
||||
},
|
||||
},
|
||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
||||
},
|
||||
env,
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||
export function serve<ViteEnv>(
|
||||
config: ViteBkndConfig<ViteEnv> = {},
|
||||
args?: ViteEnv,
|
||||
opts?: FrameworkOptions,
|
||||
) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const app = await createApp(config, env);
|
||||
const app = await createApp(config, env, opts);
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
if (!app) {
|
||||
app = await createApp(config, env);
|
||||
}
|
||||
|
||||
return app.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
|
||||
return mode === "fresh" ? serveFresh(config) : serveCached(config);
|
||||
}
|
||||
|
||||
export function devServer(options: DevServerOptions) {
|
||||
return honoViteDevServer({
|
||||
...devServerConfig,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DB, Exception } from "core";
|
||||
import { type DB, Exception, type PrimaryFieldType } from "core";
|
||||
import { addFlashMessage } from "core/server/flash";
|
||||
import {
|
||||
type Static,
|
||||
@@ -14,6 +14,7 @@ import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
import type { CookieOptions } from "hono/utils/cookie";
|
||||
import type { ServerEnv } from "modules/Controller";
|
||||
import { pick } from "lodash-es";
|
||||
|
||||
type Input = any; // workaround
|
||||
export type JWTPayload = Parameters<typeof sign>[0];
|
||||
@@ -37,11 +38,10 @@ export interface Strategy {
|
||||
}
|
||||
|
||||
export type User = {
|
||||
id: number;
|
||||
id: PrimaryFieldType;
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
export type ProfileExchange = {
|
||||
@@ -158,13 +158,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
// @todo: add jwt tests
|
||||
async jwt(user: Omit<User, "password">): Promise<string> {
|
||||
const prohibited = ["password"];
|
||||
for (const prop of prohibited) {
|
||||
if (prop in user) {
|
||||
throw new Error(`Property "${prop}" is prohibited`);
|
||||
}
|
||||
}
|
||||
async jwt(_user: Omit<User, "password">): Promise<string> {
|
||||
const user = pick(_user, this.config.jwt.fields);
|
||||
|
||||
const payload: JWTPayload = {
|
||||
...user,
|
||||
|
||||
@@ -29,13 +29,15 @@ export const cloudflare = {
|
||||
{ dir: ctx.dir },
|
||||
);
|
||||
|
||||
const db = ctx.skip ? "d1" : await $p.select({
|
||||
message: "What database do you want to use?",
|
||||
options: [
|
||||
{ label: "Cloudflare D1", value: "d1" },
|
||||
{ label: "LibSQL", value: "libsql" },
|
||||
],
|
||||
});
|
||||
const db = ctx.skip
|
||||
? "d1"
|
||||
: await $p.select({
|
||||
message: "What database do you want to use?",
|
||||
options: [
|
||||
{ label: "Cloudflare D1", value: "d1" },
|
||||
{ label: "LibSQL", value: "libsql" },
|
||||
],
|
||||
});
|
||||
if ($p.isCancel(db)) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -64,17 +66,19 @@ export const cloudflare = {
|
||||
|
||||
async function createD1(ctx: TemplateSetupCtx) {
|
||||
const default_db = "data";
|
||||
const name = ctx.skip ? default_db : await $p.text({
|
||||
message: "Enter database name",
|
||||
initialValue: default_db,
|
||||
placeholder: default_db,
|
||||
validate: (v) => {
|
||||
if (!v) {
|
||||
return "Invalid name";
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
const name = ctx.skip
|
||||
? default_db
|
||||
: await $p.text({
|
||||
message: "Enter database name",
|
||||
initialValue: default_db,
|
||||
placeholder: default_db,
|
||||
validate: (v) => {
|
||||
if (!v) {
|
||||
return "Invalid name";
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
if ($p.isCancel(name)) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -153,13 +157,16 @@ async function createLibsql(ctx: TemplateSetupCtx) {
|
||||
}
|
||||
|
||||
async function createR2(ctx: TemplateSetupCtx) {
|
||||
const create = ctx.skip ?? await $p.confirm({
|
||||
message: "Do you want to use a R2 bucket?",
|
||||
initialValue: true,
|
||||
});
|
||||
const create = ctx.skip
|
||||
? false
|
||||
: await $p.confirm({
|
||||
message: "Do you want to use a R2 bucket?",
|
||||
initialValue: true,
|
||||
});
|
||||
if ($p.isCancel(create)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!create) {
|
||||
await overrideJson(
|
||||
WRANGLER_FILE,
|
||||
@@ -173,17 +180,19 @@ async function createR2(ctx: TemplateSetupCtx) {
|
||||
}
|
||||
|
||||
const default_bucket = "bucket";
|
||||
const name = ctx.skip ? default_bucket : await $p.text({
|
||||
message: "Enter bucket name",
|
||||
initialValue: default_bucket,
|
||||
placeholder: default_bucket,
|
||||
validate: (v) => {
|
||||
if (!v) {
|
||||
return "Invalid name";
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
const name = ctx.skip
|
||||
? default_bucket
|
||||
: await $p.text({
|
||||
message: "Enter bucket name",
|
||||
initialValue: default_bucket,
|
||||
placeholder: default_bucket,
|
||||
validate: (v) => {
|
||||
if (!v) {
|
||||
return "Invalid name";
|
||||
}
|
||||
return;
|
||||
},
|
||||
});
|
||||
if ($p.isCancel(name)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@ import {
|
||||
startServer,
|
||||
} from "./platform";
|
||||
import { makeConfig } from "adapter";
|
||||
import { isBun as $isBun } from "cli/utils/sys";
|
||||
|
||||
const env_files = [".env", ".dev.vars"];
|
||||
dotenv.config({
|
||||
path: env_files.map((file) => path.resolve(process.cwd(), file)),
|
||||
});
|
||||
const isBun = typeof Bun !== "undefined";
|
||||
const isBun = $isBun();
|
||||
|
||||
export const run: CliCommand = (program) => {
|
||||
program
|
||||
@@ -98,7 +99,7 @@ export async function makeConfigApp(_config: CliBkndConfig, platform?: Platform)
|
||||
});
|
||||
}
|
||||
|
||||
async function action(options: {
|
||||
type RunOptions = {
|
||||
port: number;
|
||||
memory?: boolean;
|
||||
config?: string;
|
||||
@@ -106,8 +107,9 @@ async function action(options: {
|
||||
dbToken?: string;
|
||||
server: Platform;
|
||||
open?: boolean;
|
||||
}) {
|
||||
colorizeConsole(console);
|
||||
};
|
||||
|
||||
export async function makeAppFromEnv(options: Partial<RunOptions> = {}) {
|
||||
const configFilePath = await getConfigPath(options.config);
|
||||
|
||||
let app: App | undefined = undefined;
|
||||
@@ -147,12 +149,19 @@ async function action(options: {
|
||||
// if nothing helps, create a file based app
|
||||
if (!app) {
|
||||
const connection = { url: "file:data.db" } as Config;
|
||||
console.info("Using connection", c.cyan(connection.url));
|
||||
console.info("Using fallback connection", c.cyan(connection.url));
|
||||
app = await makeApp({
|
||||
connection,
|
||||
server: { platform: options.server },
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
async function action(options: RunOptions) {
|
||||
colorizeConsole(console);
|
||||
|
||||
const app = await makeAppFromEnv(options);
|
||||
await startServer(options.server, app, { port: options.port, open: options.open });
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { password as $password, text as $text } from "@clack/prompts";
|
||||
import {
|
||||
isCancel as $isCancel,
|
||||
log as $log,
|
||||
password as $password,
|
||||
text as $text,
|
||||
} from "@clack/prompts";
|
||||
import type { App } from "App";
|
||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||
import { makeConfigApp } from "cli/commands/run";
|
||||
import { getConfigPath } from "cli/commands/run/platform";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { makeAppFromEnv } from "cli/commands/run";
|
||||
import type { CliCommand } from "cli/types";
|
||||
import { Argument } from "commander";
|
||||
import { $console } from "core";
|
||||
import c from "picocolors";
|
||||
import { isBun } from "cli/utils/sys";
|
||||
|
||||
export const user: CliCommand = (program) => {
|
||||
program
|
||||
.command("user")
|
||||
.description("create and update user (auth)")
|
||||
.addArgument(new Argument("<action>", "action to perform").choices(["create", "update"]))
|
||||
.description("create/update users, or generate a token (auth)")
|
||||
.addArgument(
|
||||
new Argument("<action>", "action to perform").choices(["create", "update", "token"]),
|
||||
)
|
||||
.action(action);
|
||||
};
|
||||
|
||||
async function action(action: "create" | "update", options: any) {
|
||||
const configFilePath = await getConfigPath();
|
||||
if (!configFilePath) {
|
||||
console.error("config file not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||
const app = await makeConfigApp(config, options.server);
|
||||
async function action(action: "create" | "update" | "token", options: any) {
|
||||
const app = await makeAppFromEnv({
|
||||
server: "node",
|
||||
});
|
||||
|
||||
switch (action) {
|
||||
case "create":
|
||||
@@ -31,6 +35,9 @@ async function action(action: "create" | "update", options: any) {
|
||||
case "update":
|
||||
await update(app, options);
|
||||
break;
|
||||
case "token":
|
||||
await token(app, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +45,8 @@ async function create(app: App, options: any) {
|
||||
const strategy = app.module.auth.authenticator.strategy("password") as PasswordStrategy;
|
||||
|
||||
if (!strategy) {
|
||||
throw new Error("Password strategy not configured");
|
||||
$log.error("Password strategy not configured");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const email = await $text({
|
||||
@@ -50,6 +58,7 @@ async function create(app: App, options: any) {
|
||||
return;
|
||||
},
|
||||
});
|
||||
if ($isCancel(email)) process.exit(1);
|
||||
|
||||
const password = await $password({
|
||||
message: "Enter password",
|
||||
@@ -60,20 +69,17 @@ async function create(app: App, options: any) {
|
||||
return;
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof email !== "string" || typeof password !== "string") {
|
||||
console.log("Cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
if ($isCancel(password)) process.exit(1);
|
||||
|
||||
try {
|
||||
const created = await app.createUser({
|
||||
email,
|
||||
password: await strategy.hash(password as string),
|
||||
});
|
||||
console.log("Created:", created);
|
||||
$log.success(`Created user: ${c.cyan(created.email)}`);
|
||||
} catch (e) {
|
||||
console.error("Error", e);
|
||||
$log.error("Error creating user");
|
||||
$console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +98,14 @@ async function update(app: App, options: any) {
|
||||
return;
|
||||
},
|
||||
})) as string;
|
||||
if (typeof email !== "string") {
|
||||
console.log("Cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
if ($isCancel(email)) process.exit(1);
|
||||
|
||||
const { data: user } = await em.repository(users_entity).findOne({ email });
|
||||
if (!user) {
|
||||
console.log("User not found");
|
||||
process.exit(0);
|
||||
$log.error("User not found");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("User found:", user);
|
||||
$log.info(`User found: ${c.cyan(user.email)}`);
|
||||
|
||||
const password = await $password({
|
||||
message: "New Password?",
|
||||
@@ -113,10 +116,7 @@ async function update(app: App, options: any) {
|
||||
return;
|
||||
},
|
||||
});
|
||||
if (typeof password !== "string") {
|
||||
console.log("Cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
if ($isCancel(password)) process.exit(1);
|
||||
|
||||
try {
|
||||
function togglePw(visible: boolean) {
|
||||
@@ -134,8 +134,42 @@ async function update(app: App, options: any) {
|
||||
});
|
||||
togglePw(false);
|
||||
|
||||
console.log("Updated:", user);
|
||||
$log.success(`Updated user: ${c.cyan(user.email)}`);
|
||||
} catch (e) {
|
||||
console.error("Error", e);
|
||||
$log.error("Error updating user");
|
||||
$console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function token(app: App, options: any) {
|
||||
if (isBun()) {
|
||||
$log.error("Please use node to generate tokens");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = app.module.auth.toJSON(true);
|
||||
const users_entity = config.entity_name as "users";
|
||||
const em = app.modules.ctx().em;
|
||||
|
||||
const email = (await $text({
|
||||
message: "Which user? Enter email",
|
||||
validate: (v) => {
|
||||
if (!v.includes("@")) {
|
||||
return "Invalid email";
|
||||
}
|
||||
return;
|
||||
},
|
||||
})) as string;
|
||||
if ($isCancel(email)) process.exit(1);
|
||||
|
||||
const { data: user } = await em.repository(users_entity).findOne({ email });
|
||||
if (!user) {
|
||||
$log.error("User not found");
|
||||
process.exit(1);
|
||||
}
|
||||
$log.info(`User found: ${c.cyan(user.email)}`);
|
||||
|
||||
console.log(
|
||||
`\n${c.dim("Token:")}\n${c.yellow(await app.module.auth.authenticator.jwt(user))}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@ import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
|
||||
export function isBun(): boolean {
|
||||
try {
|
||||
return typeof Bun !== "undefined";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getRootPath() {
|
||||
const _path = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
// because of "src", local needs one more level up
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { App } from "App";
|
||||
import { type Context, Hono } from "hono";
|
||||
import * as middlewares from "modules/middlewares";
|
||||
import type { SafeUser } from "auth";
|
||||
|
||||
export type ServerEnv = {
|
||||
Variables: {
|
||||
@@ -10,7 +11,7 @@ export type ServerEnv = {
|
||||
resolved: boolean;
|
||||
registered: boolean;
|
||||
skip: boolean;
|
||||
user?: { id: any; role?: string; [key: string]: any };
|
||||
user?: SafeUser;
|
||||
};
|
||||
html?: string;
|
||||
};
|
||||
|
||||
@@ -14,10 +14,11 @@ const htmlBkndContextReplace = "<!-- BKND_CONTEXT -->";
|
||||
// @todo: add migration to remove admin path from config
|
||||
export type AdminControllerOptions = {
|
||||
basepath?: string;
|
||||
assets_path?: string;
|
||||
adminBasepath?: string;
|
||||
assetsPath?: string;
|
||||
html?: string;
|
||||
forceDev?: boolean | { mainPath: string };
|
||||
debug_rerenders?: boolean;
|
||||
debugRerenders?: boolean;
|
||||
};
|
||||
|
||||
export class AdminController extends Controller {
|
||||
@@ -36,7 +37,8 @@ export class AdminController extends Controller {
|
||||
return {
|
||||
...this._options,
|
||||
basepath: this._options.basepath ?? "/",
|
||||
assets_path: this._options.assets_path ?? config.server.assets_path,
|
||||
adminBasepath: this._options.adminBasepath ?? "",
|
||||
assetsPath: this._options.assetsPath ?? config.server.assets_path,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,6 +50,10 @@ export class AdminController extends Controller {
|
||||
return (this.basepath + route).replace(/(?<!:)\/+/g, "/");
|
||||
}
|
||||
|
||||
private withAdminBasePath(route: string = "") {
|
||||
return this.withBasePath(this.options.adminBasepath + route);
|
||||
}
|
||||
|
||||
override getController() {
|
||||
const { auth: authMiddleware, permission } = this.middlewares;
|
||||
const hono = this.create().use(
|
||||
@@ -63,16 +69,16 @@ export class AdminController extends Controller {
|
||||
|
||||
const authRoutes = {
|
||||
root: "/",
|
||||
success: configs.auth.cookie.pathSuccess ?? "/",
|
||||
loggedOut: configs.auth.cookie.pathLoggedOut ?? "/",
|
||||
login: "/auth/login",
|
||||
logout: "/auth/logout",
|
||||
success: configs.auth.cookie.pathSuccess ?? this.withAdminBasePath("/"),
|
||||
loggedOut: configs.auth.cookie.pathLoggedOut ?? this.withAdminBasePath("/"),
|
||||
login: this.withAdminBasePath("/auth/login"),
|
||||
logout: this.withAdminBasePath("/auth/logout"),
|
||||
};
|
||||
|
||||
hono.use("*", async (c, next) => {
|
||||
const obj = {
|
||||
user: c.get("auth")?.user,
|
||||
logout_route: this.withBasePath(authRoutes.logout),
|
||||
logout_route: this.withAdminBasePath(authRoutes.logout),
|
||||
};
|
||||
const html = await this.getHtml(obj);
|
||||
if (!html) {
|
||||
@@ -164,8 +170,8 @@ export class AdminController extends Controller {
|
||||
|
||||
if (isProd) {
|
||||
let manifest: any;
|
||||
if (this.options.assets_path.startsWith("http")) {
|
||||
manifest = await fetch(this.options.assets_path + "manifest.json", {
|
||||
if (this.options.assetsPath.startsWith("http")) {
|
||||
manifest = await fetch(this.options.assetsPath + "manifest.json", {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
@@ -182,7 +188,7 @@ export class AdminController extends Controller {
|
||||
assets.css = manifest["src/ui/main.tsx"].css[0] as any;
|
||||
}
|
||||
|
||||
const favicon = isProd ? this.options.assets_path + "favicon.ico" : "/favicon.ico";
|
||||
const favicon = isProd ? this.options.assetsPath + "favicon.ico" : "/favicon.ico";
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
@@ -197,7 +203,7 @@ export class AdminController extends Controller {
|
||||
/>
|
||||
<link rel="icon" href={favicon} type="image/x-icon" />
|
||||
<title>BKND</title>
|
||||
{this.options.debug_rerenders && (
|
||||
{this.options.debugRerenders && (
|
||||
<script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
@@ -205,8 +211,8 @@ export class AdminController extends Controller {
|
||||
)}
|
||||
{isProd ? (
|
||||
<Fragment>
|
||||
<script type="module" src={this.options.assets_path + assets?.js} />
|
||||
<link rel="stylesheet" href={this.options.assets_path + assets?.css} />
|
||||
<script type="module" src={this.options.assetsPath + assets?.js} />
|
||||
<link rel="stylesheet" href={this.options.assetsPath + assets?.css} />
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
|
||||
@@ -101,14 +101,21 @@ export function BkndProvider({
|
||||
fallback: true,
|
||||
} as any);
|
||||
|
||||
|
||||
startTransition(() => {
|
||||
document.startViewTransition(() => {
|
||||
const commit = () => {
|
||||
setSchema(newSchema);
|
||||
setWithSecrets(_includeSecrets);
|
||||
setFetched(true);
|
||||
set_local_version((v) => v + 1);
|
||||
fetching.current = Fetching.None;
|
||||
});
|
||||
}
|
||||
|
||||
if ("startViewTransition" in document) {
|
||||
document.startViewTransition(commit);
|
||||
} else {
|
||||
commit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useApi, useApiInfiniteQuery, useInvalidate } from "ui/client";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
@@ -20,6 +20,7 @@ import { useInViewport } from "@mantine/hooks";
|
||||
export type DropzoneContainerProps = {
|
||||
children?: ReactNode;
|
||||
initialItems?: MediaFieldSchema[] | false;
|
||||
infinite?: boolean;
|
||||
entity?: {
|
||||
name: string;
|
||||
id: number;
|
||||
@@ -39,6 +40,7 @@ export function DropzoneContainer({
|
||||
query,
|
||||
children,
|
||||
randomFilename,
|
||||
infinite = false,
|
||||
...props
|
||||
}: DropzoneContainerProps) {
|
||||
const id = useId();
|
||||
@@ -54,7 +56,7 @@ export function DropzoneContainer({
|
||||
const entity_name = (media?.entity_name ?? "media") as "media";
|
||||
//console.log("dropzone:baseUrl", baseUrl);
|
||||
|
||||
const selectApi = (api: Api, page: number) =>
|
||||
const selectApi = (api: Api, page: number = 0) =>
|
||||
entity
|
||||
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
||||
...query,
|
||||
@@ -70,7 +72,11 @@ export function DropzoneContainer({
|
||||
...defaultQuery(page),
|
||||
});
|
||||
|
||||
const $q = useApiInfiniteQuery(selectApi, {});
|
||||
const $q = infinite
|
||||
? useApiInfiniteQuery(selectApi, {})
|
||||
: useApiQuery(selectApi, {
|
||||
enabled: initialItems !== false && !initialItems,
|
||||
});
|
||||
|
||||
const getUploadInfo = useEvent((file) => {
|
||||
const url = entity
|
||||
@@ -108,11 +114,17 @@ export function DropzoneContainer({
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={$q._data?.[0]?.body.meta.count ?? 0}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
infinite &&
|
||||
"setSize" in $q && (
|
||||
<Footer
|
||||
items={_initialItems.length}
|
||||
length={Math.min(
|
||||
$q._data?.[0]?.body.meta.count ?? 0,
|
||||
_initialItems.length + pageSize,
|
||||
)}
|
||||
onFirstVisible={() => $q.setSize($q.size + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
@@ -142,15 +154,15 @@ const Footer = ({ items = 0, length = 0, onFirstVisible }) => {
|
||||
const _len = length - items;
|
||||
if (_len <= 0) return null;
|
||||
|
||||
return new Array(Math.max(length - items, 0)).fill(0).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={i === 0 ? ref : undefined}
|
||||
className="w-[49%] md:w-60 bg-muted aspect-square"
|
||||
>
|
||||
{i === 0 ? (inViewport ? `load ${visible}` : "first") : "other"}
|
||||
</div>
|
||||
));
|
||||
return new Array(Math.max(length - items, 0))
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
ref={i === 0 ? ref : undefined}
|
||||
className="w-[49%] md:w-60 bg-muted aspect-square"
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
export function useDropzone() {
|
||||
|
||||
@@ -35,7 +35,7 @@ export function MediaIndex() {
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone onClick={onClick} />
|
||||
<Media.Dropzone onClick={onClick} infinite />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
|
||||
+3
-1
@@ -66,7 +66,9 @@ export default {
|
||||
},
|
||||
"sync",
|
||||
);
|
||||
await app.build();
|
||||
await app.build({
|
||||
sync: !!(firstStart && example),
|
||||
});
|
||||
|
||||
// log routes
|
||||
if (firstStart) {
|
||||
|
||||
Reference in New Issue
Block a user