init opfs and sqlocal as another browser adapter

This commit is contained in:
dswbx
2025-11-25 16:21:16 +01:00
parent 5e5dc62304
commit 36e1bb1867
17 changed files with 844 additions and 110 deletions

View File

@@ -14,7 +14,7 @@
"bknd": "file:../../app",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sqlocal": "^0.14.0",
"sqlocal": "^0.16.0",
"wouter": "^3.6.0"
},
"devDependencies": {
@@ -26,7 +26,7 @@
"tailwindcss": "^4.0.14",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0",
"vite": "^7.2.4",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -1,21 +1,25 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { checksum } from "bknd/utils";
import { App, boolean, em, entity, text } from "bknd";
import { App, boolean, em, entity, text, registries } from "bknd";
import { SQLocalConnection } from "@bknd/sqlocal";
import { Route, Router, Switch } from "wouter";
import IndexPage from "~/routes/_index";
import { Center } from "~/components/Center";
import { ClientProvider } from "bknd/client";
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()));
});
}
@@ -26,7 +30,7 @@ export default function () {
.catch(console.error);
}, []);
if (!app)
if (!app || !api)
return (
<Center>
<span className="opacity-20">Loading...</span>
@@ -34,27 +38,22 @@ export default function () {
);
return (
<Router key={hash}>
<Switch>
<Route
path="/"
component={() => (
<ClientProvider api={app.getApi()}>
<IndexPage app={app} />
</ClientProvider>
)}
/>
<ClientProvider api={api}>
<Router key={hash}>
<Switch>
<Route path="/" component={() => <IndexPage app={app} />} />
<Route path="/admin/*?">
<Suspense>
<Admin config={{ basepath: "/admin", logo_return_path: "/../" }} app={app} />
</Suspense>
</Route>
<Route path="*">
<Center className="font-mono text-4xl">404</Center>
</Route>
</Switch>
</Router>
<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>
);
}
@@ -79,16 +78,26 @@ async function setup(opts?: {
if (initialized) return;
initialized = true;
const connection = new SQLocalConnection({
databasePath: ":localStorage:",
verbose: true,
});
const connection = new SQLocalConnection(
new SQLocalKysely({
databasePath: ":localStorage:",
verbose: true,
}),
);
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
@@ -99,10 +108,10 @@ async function setup(opts?: {
]);
// @todo: auth is currently not working due to POST request
/*await ctx.app.module.auth.createUser({
await ctx.app.module.auth.createUser({
email: "test@bknd.io",
password: "12345678",
});*/
});
},
},
});
@@ -112,6 +121,8 @@ async function setup(opts?: {
App.Events.AppBuiltEvent,
async () => {
await opts.onBuilt?.(app);
// @ts-ignore
window.sql = app.connection.client.sql;
},
"sync",
);

View File

@@ -0,0 +1,265 @@
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,
};
}
}

View File

@@ -1,10 +1,6 @@
import { Admin, type BkndAdminProps } from "bknd/ui";
import type { App } from "bknd";
import "bknd/dist/styles.css";
export default function AdminPage({
app,
...props
}: Omit<BkndAdminProps, "withProvider"> & { app: App }) {
return <Admin {...props} withProvider={{ api: app.getApi() }} />;
export default function AdminPage(props: BkndAdminProps) {
return <Admin {...props} />;
}

View File

@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import sqlocal from "sqlocal/vite";
// https://vite.dev/config/
// https://sqlocal.dallashoffman.com/guide/setup#vite-configuration
@@ -9,11 +10,16 @@ export default defineConfig({
optimizeDeps: {
exclude: ["sqlocal"],
},
resolve: {
dedupe: ["react", "react-dom"],
},
plugins: [
sqlocal(),
react(),
tailwindcss(),
tsconfigPaths(),
{
/* {
name: "configure-response-headers",
configureServer: (server) => {
server.middlewares.use((_req, res, next) => {
@@ -22,6 +28,6 @@ export default defineConfig({
next();
});
},
},
}, */
],
});