mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
finalized sqlocal, added BkndBrowserApp, updated react example
This commit is contained in:
@@ -1,61 +1,7 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { checksum } from "bknd/utils";
|
||||
import { App, boolean, em, entity, text, registries } from "bknd";
|
||||
import { SQLocalConnection } from "@bknd/sqlocal";
|
||||
import { Route, Router, Switch } from "wouter";
|
||||
import { boolean, em, entity, text } from "bknd";
|
||||
import { Route } from "wouter";
|
||||
import IndexPage from "~/routes/_index";
|
||||
import { Center } from "~/components/Center";
|
||||
import { type Api, ClientProvider } from "bknd/client";
|
||||
import { SQLocalKysely } from "sqlocal/kysely";
|
||||
import { OpfsStorageAdapter } from "~/OpfsStorageAdapter";
|
||||
|
||||
const Admin = lazy(() => import("~/routes/admin"));
|
||||
|
||||
export default function () {
|
||||
const [app, setApp] = useState<App | undefined>(undefined);
|
||||
const [api, setApi] = useState<Api | undefined>(undefined);
|
||||
const [hash, setHash] = useState<string>("");
|
||||
|
||||
async function onBuilt(app: App) {
|
||||
document.startViewTransition(async () => {
|
||||
setApp(app);
|
||||
setApi(app.getApi());
|
||||
setHash(await checksum(app.toJSON()));
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setup({ onBuilt })
|
||||
.then((app) => console.log("setup", app?.version()))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
if (!app || !api)
|
||||
return (
|
||||
<Center>
|
||||
<span className="opacity-20">Loading...</span>
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientProvider api={api}>
|
||||
<Router key={hash}>
|
||||
<Switch>
|
||||
<Route path="/" component={() => <IndexPage app={app} />} />
|
||||
|
||||
<Route path="/admin/*?">
|
||||
<Suspense>
|
||||
<Admin config={{ basepath: "/admin", logo_return_path: "/../" }} />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="*">
|
||||
<Center className="font-mono text-4xl">404</Center>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
import { BkndBrowserApp, type BrowserBkndConfig } from "bknd/adapter/browser";
|
||||
|
||||
const schema = em({
|
||||
todos: entity("todos", {
|
||||
@@ -70,66 +16,41 @@ declare module "bknd" {
|
||||
interface DB extends Database {}
|
||||
}
|
||||
|
||||
let initialized = false;
|
||||
async function setup(opts?: {
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
}) {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
const config = {
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
adminConfig: {
|
||||
basepath: "/admin",
|
||||
logo_return_path: "/../",
|
||||
},
|
||||
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 },
|
||||
]);
|
||||
|
||||
const connection = new SQLocalConnection(
|
||||
new SQLocalKysely({
|
||||
databasePath: ":localStorage:",
|
||||
verbose: true,
|
||||
}),
|
||||
// @todo: auth is currently not working due to POST request
|
||||
await ctx.app.module.auth.createUser({
|
||||
email: "test@bknd.io",
|
||||
password: "12345678",
|
||||
});
|
||||
},
|
||||
},
|
||||
} satisfies BrowserBkndConfig;
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BkndBrowserApp {...config}>
|
||||
<Route path="/" component={IndexPage} />
|
||||
</BkndBrowserApp>
|
||||
);
|
||||
|
||||
registries.media.register("opfs", OpfsStorageAdapter);
|
||||
|
||||
const app = App.create({
|
||||
connection,
|
||||
// an initial config is only applied if the database is empty
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
secret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
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 },
|
||||
]);
|
||||
|
||||
// @todo: auth is currently not working due to POST request
|
||||
await ctx.app.module.auth.createUser({
|
||||
email: "test@bknd.io",
|
||||
password: "12345678",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (opts?.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
App.Events.AppBuiltEvent,
|
||||
async () => {
|
||||
await opts.onBuilt?.(app);
|
||||
// @ts-ignore
|
||||
window.sql = app.connection.client.sql;
|
||||
},
|
||||
"sync",
|
||||
);
|
||||
}
|
||||
|
||||
await opts?.beforeBuild?.(app);
|
||||
await app.build({ sync: true });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
|
||||
import { StorageAdapter, guessMimeType } from "bknd";
|
||||
import { parse, s, isFile, isBlob } from "bknd/utils";
|
||||
|
||||
export const opfsAdapterConfig = s.object(
|
||||
{
|
||||
root: s.string({ default: "" }).optional(),
|
||||
},
|
||||
{
|
||||
title: "OPFS",
|
||||
description: "Origin Private File System storage",
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
|
||||
|
||||
/**
|
||||
* Storage adapter for OPFS (Origin Private File System)
|
||||
* Provides browser-based file storage using the File System Access API
|
||||
*/
|
||||
export class OpfsStorageAdapter extends StorageAdapter {
|
||||
private config: OpfsAdapterConfig;
|
||||
private rootPromise: Promise<FileSystemDirectoryHandle>;
|
||||
|
||||
constructor(config: Partial<OpfsAdapterConfig> = {}) {
|
||||
super();
|
||||
this.config = parse(opfsAdapterConfig, config);
|
||||
this.rootPromise = this.initializeRoot();
|
||||
}
|
||||
|
||||
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
|
||||
const opfsRoot = await navigator.storage.getDirectory();
|
||||
if (!this.config.root) {
|
||||
return opfsRoot;
|
||||
}
|
||||
|
||||
// navigate to or create nested directory structure
|
||||
const parts = this.config.root.split("/").filter(Boolean);
|
||||
let current = opfsRoot;
|
||||
for (const part of parts) {
|
||||
current = await current.getDirectoryHandle(part, { create: true });
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return opfsAdapterConfig;
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return "opfs";
|
||||
}
|
||||
|
||||
async listObjects(prefix?: string): Promise<FileListObject[]> {
|
||||
const root = await this.rootPromise;
|
||||
const files: FileListObject[] = [];
|
||||
|
||||
for await (const [name, handle] of root.entries()) {
|
||||
if (handle.kind === "file") {
|
||||
if (!prefix || name.startsWith(prefix)) {
|
||||
const file = await (handle as FileSystemFileHandle).getFile();
|
||||
files.push({
|
||||
key: name,
|
||||
last_modified: new Date(file.lastModified),
|
||||
size: file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
// wrap the hex string in quotes for ETag format
|
||||
return `"${hashHex}"`;
|
||||
}
|
||||
|
||||
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
|
||||
if (body === null) {
|
||||
throw new Error("Body is empty");
|
||||
}
|
||||
|
||||
const root = await this.rootPromise;
|
||||
const fileHandle = await root.getFileHandle(key, { create: true });
|
||||
const writable = await fileHandle.createWritable();
|
||||
|
||||
try {
|
||||
let contentBuffer: ArrayBuffer;
|
||||
|
||||
if (isFile(body)) {
|
||||
contentBuffer = await body.arrayBuffer();
|
||||
await writable.write(contentBuffer);
|
||||
} else if (body instanceof ReadableStream) {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = body.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
await writable.write(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
// compute total size and combine chunks for etag
|
||||
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const combined = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
contentBuffer = combined.buffer;
|
||||
} else if (isBlob(body)) {
|
||||
contentBuffer = await (body as Blob).arrayBuffer();
|
||||
await writable.write(contentBuffer);
|
||||
} else {
|
||||
// body is ArrayBuffer or ArrayBufferView
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
const view = body as ArrayBufferView;
|
||||
contentBuffer = view.buffer.slice(
|
||||
view.byteOffset,
|
||||
view.byteOffset + view.byteLength,
|
||||
) as ArrayBuffer;
|
||||
} else {
|
||||
contentBuffer = body as ArrayBuffer;
|
||||
}
|
||||
await writable.write(body);
|
||||
}
|
||||
|
||||
await writable.close();
|
||||
return await this.computeEtagFromArrayBuffer(contentBuffer);
|
||||
} catch (error) {
|
||||
await writable.abort();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
try {
|
||||
const root = await this.rootPromise;
|
||||
await root.removeEntry(key);
|
||||
} catch {
|
||||
// file doesn't exist, which is fine
|
||||
}
|
||||
}
|
||||
|
||||
async objectExists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const root = await this.rootPromise;
|
||||
await root.getFileHandle(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private parseRangeHeader(
|
||||
rangeHeader: string,
|
||||
fileSize: number,
|
||||
): { start: number; end: number } | null {
|
||||
// parse "bytes=start-end" format
|
||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, startStr, endStr] = match;
|
||||
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
||||
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
||||
|
||||
// handle suffix-byte-range-spec (e.g., "bytes=-500")
|
||||
if (!startStr && endStr) {
|
||||
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
// validate range
|
||||
if (start < 0 || end >= fileSize || start > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
try {
|
||||
const root = await this.rootPromise;
|
||||
const fileHandle = await root.getFileHandle(key);
|
||||
const file = await fileHandle.getFile();
|
||||
const fileSize = file.size;
|
||||
const mimeType = guessMimeType(key);
|
||||
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const rangeHeader = headers.get("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
||||
|
||||
if (!range) {
|
||||
// invalid range - return 416 Range Not Satisfiable
|
||||
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
||||
return new Response("", {
|
||||
status: 416,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const chunk = arrayBuffer.slice(start, end + 1);
|
||||
|
||||
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
||||
responseHeaders.set("Content-Length", chunk.byteLength.toString());
|
||||
|
||||
return new Response(chunk, {
|
||||
status: 206, // Partial Content
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} else {
|
||||
// normal request - return entire file
|
||||
const content = await file.arrayBuffer();
|
||||
responseHeaders.set("Content-Length", content.byteLength.toString());
|
||||
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// handle file reading errors
|
||||
return new Response("", { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
getObjectUrl(_key: string): string {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async getObjectMeta(key: string): Promise<FileMeta> {
|
||||
const root = await this.rootPromise;
|
||||
const fileHandle = await root.getFileHandle(key);
|
||||
const file = await fileHandle.getFile();
|
||||
|
||||
return {
|
||||
type: guessMimeType(key) || "application/octet-stream",
|
||||
size: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
toJSON(_secrets?: boolean) {
|
||||
return {
|
||||
type: this.getName(),
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Center } from "~/components/Center";
|
||||
import type { App } from "bknd";
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
import type { SQLocalConnection } from "@bknd/sqlocal/src";
|
||||
import type { SQLocalConnection } from "bknd";
|
||||
import { useApp } from "bknd/adapter/browser";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
|
||||
export default function IndexPage({ app }: { app: App }) {
|
||||
export default function IndexPage() {
|
||||
//const user = app.getApi().getUser();
|
||||
const limit = 5;
|
||||
const { data: todos, ...$q } = useEntityQuery("todos", undefined, {
|
||||
@@ -80,7 +81,7 @@ export default function IndexPage({ app }: { app: App }) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<a href="/admin">Go to Admin ➝</a>
|
||||
<Link to="/admin">Go to Admin ➝</Link>
|
||||
{/*<div className="opacity-50 text-sm">
|
||||
{user ? (
|
||||
<p>
|
||||
@@ -91,12 +92,13 @@ export default function IndexPage({ app }: { app: App }) {
|
||||
)}
|
||||
</div>*/}
|
||||
</div>
|
||||
<Debug app={app} />
|
||||
<Debug />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function Debug({ app }: { app: App }) {
|
||||
function Debug() {
|
||||
const { app } = useApp();
|
||||
const [info, setInfo] = useState<any>();
|
||||
const connection = app.em.connection as SQLocalConnection;
|
||||
|
||||
@@ -128,6 +130,7 @@ function Debug({ app }: { app: App }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-foreground/20 leading-none py-2 px-3.5 rounded-lg text-sm hover:bg-foreground/30 transition-colors cursor-pointer"
|
||||
onClick={download}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user