updated examples: astro, nextjs, remix, bun, node

This commit is contained in:
dswbx
2024-12-23 16:50:26 +01:00
parent a17fd2df67
commit 70e42a02d7
31 changed files with 319 additions and 35 deletions

View File

@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import * as large from "../../src/media/storage/mime-types";
import * as tiny from "../../src/media/storage/mime-types-tiny";
describe("media/mime-types", () => {
test("tiny resolves", () => {
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
for (const [ext, mime] of tests) {
expect(tiny.guess(ext)).toBe(mime);
}
});
test("all tiny resolves to large", () => {
for (const [ext, mime] of Object.entries(tiny.M)) {
expect(large.guessMimeType("." + ext)).toBe(mime);
}
for (const [type, exts] of Object.entries(tiny.Q)) {
for (const ext of exts) {
const ex = `${type}/${ext}`;
try {
expect(large.guessMimeType("." + ext)).toBe(ex);
} catch (e) {
console.log(`Failed for ${ext}`, {
type,
exts,
ext,
expected: ex,
actual: large.guessMimeType("." + ext)
});
throw new Error(`Failed for ${ext}`);
}
}
}
});
});

View File

@@ -128,6 +128,14 @@ export class Api {
}; };
} }
async getVerifiedAuthState(force?: boolean): Promise<AuthState> {
if (force === true || !this.verified) {
await this.verifyAuth();
}
return this.getAuthState();
}
async verifyAuth() { async verifyAuth() {
try { try {
const res = await this.auth.me(); const res = await this.auth.me();

View File

@@ -18,11 +18,11 @@ export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
} }
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: CreateAppConfig & { beforeBuild?: (app: App) => Promise<void> }) {
return async (args: TAstro) => { return async (args: TAstro) => {
if (!app) { if (!app) {
app = App.create(config); app = App.create(config);
await config.beforeBuild?.(app);
await app.build(); await app.build();
} }
return app.fetch(args.request); return app.fetch(args.request);

View File

@@ -1,9 +1,10 @@
/// <reference types="bun-types" /> /// <reference types="bun-types" />
import path from "node:path"; import path from "node:path";
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig, registries } from "bknd";
import type { Serve, ServeOptions } from "bun"; import type { Serve, ServeOptions } from "bun";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { registerLocalMediaAdapter } from "../index";
let app: App; let app: App;
export type ExtendedAppCreateConfig = Partial<CreateAppConfig> & { export type ExtendedAppCreateConfig = Partial<CreateAppConfig> & {
@@ -18,6 +19,7 @@ export async function createApp({
buildOptions, buildOptions,
...config ...config
}: ExtendedAppCreateConfig) { }: ExtendedAppCreateConfig) {
registerLocalMediaAdapter();
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {

View File

@@ -1,5 +1,6 @@
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
import type { App, CreateAppConfig } from "bknd"; import { type App, type CreateAppConfig, registries } from "bknd";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
export type CloudflareBkndConfig<Env = any> = { export type CloudflareBkndConfig<Env = any> = {
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
@@ -47,3 +48,7 @@ export function nodeRequestToRequest(req: IncomingMessage): Request {
headers headers
}); });
} }
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}

View File

@@ -18,7 +18,6 @@ type GetServerSidePropsContext = {
export function createApi({ req }: GetServerSidePropsContext) { export function createApi({ req }: GetServerSidePropsContext) {
const request = nodeRequestToRequest(req); const request = nodeRequestToRequest(req);
//console.log("createApi:request.headers", request.headers);
return new Api({ return new Api({
host: new URL(request.url).origin, host: new URL(request.url).origin,
headers: request.headers headers: request.headers
@@ -43,10 +42,11 @@ function getCleanRequest(req: Request) {
} }
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: CreateAppConfig & { beforeBuild?: (app: App) => Promise<void> }) {
return async (req: Request) => { return async (req: Request) => {
if (!app) { if (!app) {
app = App.create(config); app = App.create(config);
await config.beforeBuild?.(app);
await app.build(); await app.build();
} }
const request = getCleanRequest(req); const request = getCleanRequest(req);

View File

@@ -3,3 +3,4 @@ export {
StorageLocalAdapter, StorageLocalAdapter,
type LocalAdapterConfig type LocalAdapterConfig
} from "../../media/storage/adapters/StorageLocalAdapter"; } from "../../media/storage/adapters/StorageLocalAdapter";
export { registerLocalMediaAdapter } from "../index";

View File

@@ -1,7 +1,8 @@
import path from "node:path"; import path from "node:path";
import { serve as honoServe } from "@hono/node-server"; import { serve as honoServe } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig, registries } from "bknd";
import { registerLocalMediaAdapter } from "../index";
export type NodeAdapterOptions = CreateAppConfig & { export type NodeAdapterOptions = CreateAppConfig & {
relativeDistPath?: string; relativeDistPath?: string;
@@ -21,6 +22,8 @@ export function serve({
buildOptions = {}, buildOptions = {},
...config ...config
}: NodeAdapterOptions = {}) { }: NodeAdapterOptions = {}) {
registerLocalMediaAdapter();
const root = path.relative( const root = path.relative(
process.cwd(), process.cwd(),
path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static") path.resolve(relativeDistPath ?? "./node_modules/bknd/dist", "static")

View File

@@ -1,10 +1,11 @@
import { App, type CreateAppConfig } from "bknd"; import { App, type CreateAppConfig } from "bknd";
let app: App; let app: App;
export function serve(config: CreateAppConfig) { export function serve(config: CreateAppConfig & { beforeBuild?: (app: App) => Promise<void> }) {
return async (args: { request: Request }) => { return async (args: { request: Request }) => {
if (!app) { if (!app) {
app = App.create(config); app = App.create(config);
await config.beforeBuild?.(app);
await app.build(); await app.build();
} }
return app.fetch(args.request); return app.fetch(args.request);

View File

@@ -1,4 +1,5 @@
import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth"; import { type AuthAction, Authenticator, type ProfileExchange, Role, type Strategy } from "auth";
import type { PasswordStrategy } from "auth/authenticate/strategies";
import { Exception } from "core"; import { Exception } from "core";
import { type Static, secureRandomString, transformObject } from "core/utils"; import { type Static, secureRandomString, transformObject } from "core/utils";
import { type Entity, EntityIndex, type EntityManager } from "data"; import { type Entity, EntityIndex, type EntityManager } from "data";
@@ -284,6 +285,21 @@ export class AppAuth extends Module<typeof authConfigSchema> {
} catch (e) {} } catch (e) {}
} }
async createUser(input: { email: string; password: string }) {
const strategy = "password";
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
const strategy_value = await pw.hash(input.password);
const mutator = this.em.mutator(this.config.entity_name as "users");
mutator.__unstable_toggleSystemEntityCreation(false);
const { data: created } = await mutator.insertOne({
email: input.email,
strategy,
strategy_value
});
mutator.__unstable_toggleSystemEntityCreation(true);
return created;
}
override toJSON(secrets?: boolean): AppAuthSchema { override toJSON(secrets?: boolean): AppAuthSchema {
if (!this.config.enabled) { if (!this.config.enabled) {
return this.configDefault; return this.configDefault;

View File

@@ -1,7 +1,7 @@
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises"; import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
import { type Static, Type, parse } from "core/utils"; import { type Static, Type, parse } from "core/utils";
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage"; import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
import { guessMimeType } from "../../mime-types"; import { guess } from "../../mime-types-tiny";
export const localAdapterConfig = Type.Object( export const localAdapterConfig = Type.Object(
{ {
@@ -83,7 +83,7 @@ export class StorageLocalAdapter implements StorageAdapter {
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
try { try {
const content = await readFile(`${this.config.path}/${key}`); const content = await readFile(`${this.config.path}/${key}`);
const mimeType = guessMimeType(key); const mimeType = guess(key);
return new Response(content, { return new Response(content, {
status: 200, status: 200,
@@ -105,7 +105,7 @@ export class StorageLocalAdapter implements StorageAdapter {
async getObjectMeta(key: string): Promise<FileMeta> { async getObjectMeta(key: string): Promise<FileMeta> {
const stats = await stat(`${this.config.path}/${key}`); const stats = await stat(`${this.config.path}/${key}`);
return { return {
type: guessMimeType(key) || "application/octet-stream", type: guess(key) || "application/octet-stream",
size: stats.size size: stats.size
}; };
} }

View File

@@ -0,0 +1,77 @@
export const Q = {
video: ["mp4", "webm"],
audio: ["ogg"],
image: ["jpeg", "png", "gif", "webp", "bmp", "tiff"],
text: ["html", "css", "mdx", "yaml", "vcard", "csv", "vtt"],
application: ["zip", "xml", "toml", "json", "json5"],
font: ["woff", "woff2", "ttf", "otf"]
} as const;
// reduced
const c = {
vnd: "vnd.openxmlformats-officedocument",
z: "application/x-7z-compressed",
t: (w = "plain") => `text/${w}`,
a: (w = "octet-stream") => `application/${w}`,
i: (w) => `image/${w}`,
v: (w) => `video/${w}`
} as const;
export const M = new Map<string, string>([
["7z", c.z],
["7zip", c.z],
["ai", c.a("pdf")],
["apk", c.a("vnd.android.package-archive")],
["doc", c.a("msword")],
["docx", `${c.vnd}.wordprocessingml.document`],
["eps", c.a("postscript")],
["epub", c.a("epub+zip")],
["ini", c.t()],
["jar", c.a("java-archive")],
["jsonld", c.a("ld+json")],
["jpg", c.i("jpeg")],
["log", c.t()],
["m3u", c.t()],
["m3u8", c.a("vnd.apple.mpegurl")],
["manifest", c.t("cache-manifest")],
["md", c.t("markdown")],
["mkv", c.v("x-matroska")],
["mp3", c.a("mpeg")],
["mobi", c.a("x-mobipocket-ebook")],
["ppt", c.a("powerpoint")],
["pptx", `${c.vnd}.presentationml.presentation`],
["qt", c.v("quicktime")],
["svg", c.i("svg+xml")],
["tif", c.i("tiff")],
["tsv", c.t("tab-separated-values")],
["tgz", c.a("x-tar")],
["txt", c.t()],
["text", c.t()],
["vcd", c.a("x-cdlink")],
["vcs", c.t("x-vcalendar")],
["wav", c.a("x-wav")],
["webmanifest", c.a("manifest+json")],
["xls", c.a("vnd.ms-excel")],
["xlsx", `${c.vnd}.spreadsheetml.sheet`],
["yml", c.t("yaml")]
]);
export function guess(f: string): string {
try {
const e = f.split(".").pop() as string;
if (!e) {
return c.a();
}
// try quick first
for (const [t, _e] of Object.entries(Q)) {
// @ts-ignore
if (_e.includes(e)) {
return `${t}/${e}`;
}
}
return M.get(e!) as string;
} catch (e) {
return c.a();
}
}

View File

@@ -63,7 +63,7 @@ const Skeleton = ({ theme = "light" }: { theme?: string }) => {
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
> >
<div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"> <div className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none">
<Logo /> <Logo theme={theme} />
</div> </div>
<nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center"> <nav className="hidden md:flex flex-row gap-2.5 pl-0 p-2.5 items-center">
{[...new Array(5)].map((item, key) => ( {[...new Array(5)].map((item, key) => (

View File

@@ -42,7 +42,11 @@ const useLocationFromRouter = (router) => {
]; ];
}; };
export function Link({ className, ...props }: { className?: string } & LinkProps) { export function Link({
className,
native,
...props
}: { className?: string; native?: boolean } & LinkProps) {
const router = useRouter(); const router = useRouter();
const [path, navigate] = useLocationFromRouter(router); const [path, navigate] = useLocationFromRouter(router);
@@ -55,8 +59,6 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
return false; return false;
} }
function handleClick(e) {}
const _href = props.href ?? props.to; const _href = props.href ?? props.to;
const href = router const href = router
.hrefs( .hrefs(
@@ -72,6 +74,10 @@ export function Link({ className, ...props }: { className?: string } & LinkProps
/*if (active) { /*if (active) {
console.log("link", { a, path, absPath, href, to, active, router }); console.log("link", { a, path, absPath, href, to, active, router });
}*/ }*/
if (native) {
return <a className={`${active ? "active " : ""}${className}`} {...props} />;
}
return ( return (
// @ts-expect-error className is not typed on WouterLink // @ts-expect-error className is not typed on WouterLink
<WouterLink className={`${active ? "active " : ""}${className}`} {...props} /> <WouterLink className={`${active ? "active " : ""}${className}`} {...props} />

View File

@@ -116,7 +116,7 @@ function SidebarToggler() {
export function Header({ hasSidebar = true }) { export function Header({ hasSidebar = true }) {
//const logoReturnPath = ""; //const logoReturnPath = "";
const { app } = useBknd(); const { app } = useBknd();
const logoReturnPath = app.getAdminConfig().logo_return_path ?? "/"; const { logo_return_path = "/", color_scheme = "light" } = app.getAdminConfig();
return ( return (
<header <header
@@ -124,11 +124,11 @@ export function Header({ hasSidebar = true }) {
className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10" className="flex flex-row w-full h-16 gap-2.5 border-muted border-b justify-start bg-muted/10"
> >
<Link <Link
href={logoReturnPath} href={logo_return_path}
replace native={logo_return_path !== "/"}
className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none" className="max-h-full flex hover:bg-primary/5 link p-2.5 w-[134px] outline-none"
> >
<Logo /> <Logo theme={color_scheme} />
</Link> </Link>
<HeaderNavigation /> <HeaderNavigation />
<div className="flex flex-grow" /> <div className="flex flex-grow" />

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,5 +1,5 @@
{ {
"_variables": { "_variables": {
"lastUpdateCheck": 1732785435939 "lastUpdateCheck": 1734966049246
} }
} }

View File

@@ -22,3 +22,4 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
*.db

View File

@@ -14,7 +14,7 @@ export const prerender = false;
<body> <body>
<Admin <Admin
withProvider={{ user }} withProvider={{ user }}
config={{ basepath: "/admin", color_scheme: "dark" }} config={{ basepath: "/admin", color_scheme: "dark", logo_return_path: "/../" }}
client:only client:only
/> />
</body> </body>

View File

@@ -1,12 +1,70 @@
import { App } from "bknd";
import { serve } from "bknd/adapter/astro"; 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";
export const prerender = false; export const prerender = false;
// since we're running in node, we can register the local media adapter
registerLocalMediaAdapter();
export const ALL = serve({ export const ALL = serve({
// we can use any libsql config, and if omitted, uses in-memory
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "file:test.db" url: "file:test.db"
} }
},
// an initial config is only applied if the database is empty
initialConfig: {
// the em() function makes it easy to create an initial schema
data: em({
todos: entity("todos", {
title: text(),
done: boolean()
})
}).toJSON(),
// we're enabling auth ...
auth: {
enabled: true,
jwt: {
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"
);
} }
}); });

Binary file not shown.

View File

@@ -38,3 +38,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
!test.db

View File

@@ -1,5 +1,7 @@
import { withApi } from "bknd/adapter/nextjs"; import type { InferGetServerSidePropsType } from "next";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { withApi } from "bknd/adapter/nextjs";
import "bknd/dist/styles.css"; import "bknd/dist/styles.css";
const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), {
@@ -14,7 +16,11 @@ export const getServerSideProps = withApi(async (context) => {
}; };
}); });
export default function AdminPage() { export default function AdminPage({
user
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (typeof document === "undefined") return null; if (typeof document === "undefined") return null;
return <Admin withProvider config={{ basepath: "/admin" }} />; return (
<Admin withProvider={{ user }} config={{ basepath: "/admin", logo_return_path: "/../" }} />
);
} }

View File

@@ -1,7 +1,7 @@
import { serve } from "bknd/adapter/nextjs"; import { serve } from "bknd/adapter/nextjs";
export const config = { export const config = {
runtime: "experimental-edge", runtime: "edge",
// add a matcher for bknd dist to allow dynamic otherwise build may fail. // add a matcher for bknd dist to allow dynamic otherwise build may fail.
// inside this repo it's '../../app/dist/index.js', outside probably inside node_modules // inside this repo it's '../../app/dist/index.js', outside probably inside node_modules
// see https://github.com/vercel/next.js/issues/51401 // see https://github.com/vercel/next.js/issues/51401

View File

@@ -3,3 +3,4 @@ node_modules
/.cache /.cache
/build /build
.env .env
*.db

View File

@@ -35,8 +35,9 @@ export const loader = async (args: LoaderFunctionArgs) => {
// add api to the context // add api to the context
args.context.api = api; args.context.api = api;
await api.verifyAuth();
return { return {
user: api.getAuthState().user user: api.getAuthState()?.user
}; };
}; };

View File

@@ -7,7 +7,7 @@ export const meta: MetaFunction = () => {
export const loader = async (args: LoaderFunctionArgs) => { export const loader = async (args: LoaderFunctionArgs) => {
const api = args.context.api; const api = args.context.api;
const user = api.getAuthState().user; const user = (await api.getVerifiedAuthState()).user;
const { data } = await api.data.readMany("todos"); const { data } = await api.data.readMany("todos");
return { data, user }; return { data, user };
}; };

View File

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

View File

@@ -1,11 +1,70 @@
import { App } from "bknd";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { serve } from "bknd/adapter/remix"; 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
registerLocalMediaAdapter();
const handler = serve({ const handler = serve({
// we can use any libsql config, and if omitted, uses in-memory
connection: { connection: {
type: "libsql", type: "libsql",
config: { config: {
url: "file:test.db" url: "file:test.db"
} }
},
// an initial config is only applied if the database is empty
initialConfig: {
// the em() function makes it easy to create an initial schema
data: em({
todos: entity("todos", {
title: text(),
done: boolean()
})
}).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"
);
} }
}); });

View File

@@ -12,17 +12,17 @@
"typecheck": "tsc" "typecheck": "tsc"
}, },
"dependencies": { "dependencies": {
"@remix-run/node": "^2.14.0", "@remix-run/node": "^2.15.2",
"@remix-run/react": "^2.14.0", "@remix-run/react": "^2.15.2",
"@remix-run/serve": "^2.14.0", "@remix-run/serve": "^2.15.2",
"bknd": "workspace:*", "bknd": "workspace:*",
"isbot": "^4.1.0", "isbot": "^5.1.18",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"remix-utils": "^7.7.0" "remix-utils": "^8.0.0"
}, },
"devDependencies": { "devDependencies": {
"@remix-run/dev": "^2.14.0", "@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",
"typescript": "^5.1.6", "typescript": "^5.1.6",

Binary file not shown.