Compare commits

..

9 Commits

Author SHA1 Message Date
dswbx 9ecfcb3e05 Merge pull request #91 from bknd-io/release/0.8.1
Fix Release 0.8.1
2025-02-21 08:18:32 +01:00
dswbx fb3dbd343b bump fix release version to 0.8.1 2025-02-21 08:18:13 +01:00
dswbx 060df210f9 fix Api import in App 2025-02-21 08:14:22 +01:00
dswbx fb725be0ed dev: only report env once 2025-02-21 07:44:07 +01:00
dswbx 1a95b4c8bb Merge remote-tracking branch 'origin/release/0.8.1' into release/0.8.1
# Conflicts:
#	app/src/ui/elements/media/Dropzone.tsx
2025-02-21 07:41:45 +01:00
dswbx cfa950aa46 fix media upload without name, add data result to uploaded payload 2025-02-21 07:40:43 +01:00
dswbx 5771298bfa Merge pull request #90 from bknd-io/fix/media-mime-guessing-on-entity
fix media mime guessing when uploading to entity
2025-02-20 11:34:36 +01:00
dswbx 94c40de011 fix media mime guessing when uploading to entity 2025-02-20 11:32:24 +01:00
dswbx eaa7276173 update docs on new cli starters 2025-02-19 21:27:04 +01:00
26 changed files with 264 additions and 102 deletions
+4 -4
View File
@@ -18,10 +18,10 @@ bknd simplifies app development by providing a fully functional backend for data
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Size
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@0.8.0/dist/ui/index.js?compression=gzip&label=bknd/ui)
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/index.js?compression=gzip&label=bknd)
![gzipped size of bknd/client](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/ui/client/index.js?compression=gzip&label=bknd/client)
![gzipped size of bknd/elements](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/ui/elements/index.js?compression=gzip&label=bknd/elements)
![gzipped size of bknd/ui](https://img.badgesize.io/https://unpkg.com/bknd@0.7.1/dist/ui/index.js?compression=gzip&label=bknd/ui)
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
+1
View File
@@ -111,6 +111,7 @@ describe("EventManager", async () => {
emgr.onEvent(ReturnEvent, async () => "1", "sync");
emgr.onEvent(ReturnEvent, async () => "0", "sync");
// @todo: fix this
// @ts-expect-error must be string
emgr.onEvent(ReturnEvent, async () => 0, "sync");
+17
View File
@@ -34,4 +34,21 @@ describe("media/mime-types", () => {
}
}
});
test("isMimeType", () => {
expect(tiny.isMimeType("image/jpeg")).toBe(true);
expect(tiny.isMimeType("image/jpeg", ["image/png"])).toBe(true);
expect(tiny.isMimeType("image/png", ["image/png"])).toBe(false);
expect(tiny.isMimeType("image/png")).toBe(true);
expect(tiny.isMimeType("whatever")).toBe(false);
expect(tiny.isMimeType("text/tab-separated-values")).toBe(true);
});
test("extension", () => {
expect(tiny.extension("image/png")).toBe("png");
expect(tiny.extension("image/jpeg")).toBe("jpeg");
expect(tiny.extension("application/zip")).toBe("zip");
expect(tiny.extension("text/tab-separated-values")).toBe("tsv");
expect(tiny.extension("application/zip")).toBe("zip");
});
});
+3 -3
View File
@@ -3,7 +3,7 @@
"type": "module",
"sideEffects": false,
"bin": "./dist/cli/index.js",
"version": "0.8.0",
"version": "0.8.1",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io",
"repository": {
@@ -22,9 +22,9 @@
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"build:static": "vite build",
"watch": "bun run build.ts --types --watch",
"types": "bun tsc --noEmit",
"types": "bun tsc -p tsconfig.build.json --noEmit",
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
"updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
+1 -1
View File
@@ -1,5 +1,5 @@
import { Api, type ApiOptions } from "Api";
import type { CreateUserPayload } from "auth/AppAuth";
import { Api, type ApiOptions } from "bknd/client";
import { $console } from "core";
import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
+1 -1
View File
@@ -220,7 +220,7 @@ export class DataController extends Controller {
return c.notFound();
}
const where = c.req.json() as any;
const where = (await c.req.json()) as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
}
+2 -1
View File
@@ -124,8 +124,9 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
const mutator = em.mutator(media);
mutator.__unstable_toggleSystemEntityCreation(false);
const payload = this.uploadedEventDataToMediaPayload(e.params);
await mutator.insertOne(payload);
const { data } = await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true);
return { data };
},
{ mode: "sync", id: "add-data-media" }
);
+2 -1
View File
@@ -44,7 +44,8 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
return (await res.blob()) as File;
}
getFileUploadUrl(file: FileWithPath): string {
getFileUploadUrl(file?: FileWithPath): string {
if (!file) return this.getUrl("/upload");
return this.getUrl(`/upload/${file.path}`);
}
+6 -7
View File
@@ -72,11 +72,8 @@ export class MediaController extends Controller {
// upload file
// @todo: add required type for "upload endpoints"
hono.post("/upload/:filename", async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
hono.post("/upload/:filename?", async (c) => {
const reqname = c.req.param("filename");
const body = await getFileFromContext(c);
if (!body) {
@@ -89,7 +86,9 @@ export class MediaController extends Controller {
);
}
const filename = reqname ?? getRandomizedFilename(body as File);
const res = await this.getStorage().uploadFile(body, filename);
return c.json(res, HttpStatus.CREATED);
});
@@ -191,8 +190,8 @@ export class MediaController extends Controller {
);
}
const file_name = getRandomizedFilename(file as File);
const info = await this.getStorage().uploadFile(file, file_name, true);
const filename = getRandomizedFilename(file as File);
const info = await this.getStorage().uploadFile(file, filename, true);
const mutator = this.media.em.mutator(media_entity);
mutator.__unstable_toggleSystemEntityCreation(false);
+4 -1
View File
@@ -119,7 +119,10 @@ export class Storage implements EmitsEvents {
}
};
if (!noEmit) {
await this.emgr.emit(new StorageEvents.FileUploadedEvent(eventData));
const result = await this.emgr.emit(new StorageEvents.FileUploadedEvent(eventData));
if (result.returned) {
return result.params;
}
}
return eventData;
+14 -2
View File
@@ -1,11 +1,23 @@
import { Event } from "core/events";
import { Event, InvalidEventReturn } from "core/events";
import type { FileBody, FileUploadPayload } from "../Storage";
export type FileUploadedEventData = FileUploadPayload & {
file: FileBody;
};
export class FileUploadedEvent extends Event<FileUploadedEventData> {
export class FileUploadedEvent extends Event<FileUploadedEventData, object> {
static override slug = "file-uploaded";
override validate(data: object) {
if (typeof data !== "object") {
throw new InvalidEventReturn("object", typeof data);
}
return this.clone({
// prepending result, so original is always kept
...data,
...this.params
});
}
}
export class FileDeletedEvent extends Event<{ name: string }> {
+19
View File
@@ -77,6 +77,17 @@ export function guess(f: string): string {
}
export function isMimeType(mime: any, exclude: string[] = []) {
if (exclude.includes(mime)) return false;
// try quick first
if (
Object.entries(Q)
.flatMap(([t, e]) => e.map((x) => `${t}/${x}`))
.includes(mime)
) {
return true;
}
for (const [k, v] of M.entries()) {
if (v === mime && !exclude.includes(k)) {
return true;
@@ -86,6 +97,14 @@ export function isMimeType(mime: any, exclude: string[] = []) {
}
export function extension(mime: string) {
for (const [t, e] of Object.entries(Q)) {
for (const _e of e) {
if (mime === `${t}/${_e}`) {
return _e;
}
}
}
for (const [k, v] of M.entries()) {
if (v === mime) {
return k;
+11 -4
View File
@@ -1,6 +1,7 @@
import { randomString } from "core/utils";
import { isFile, randomString } from "core/utils";
import { extension } from "media/storage/mime-types-tiny";
export function getExtension(filename: string): string | undefined {
export function getExtensionFromName(filename: string): string | undefined {
if (!filename.includes(".")) return;
const parts = filename.split(".");
@@ -17,6 +18,12 @@ export function getRandomizedFilename(file: File | string, length = 16): string
throw new Error("Invalid file name");
}
// @todo: use uuid instead?
return [randomString(length), getExtension(filename)].filter(Boolean).join(".");
let ext = getExtensionFromName(filename);
if (isFile(file) && file.type) {
const _ext = extension(file.type);
if (_ext.length > 0) ext = _ext;
}
// @todo: use uuid instead?
return [randomString(length), ext].filter(Boolean).join(".");
}
+1
View File
@@ -10,4 +10,5 @@ export * from "./api/use-api";
export * from "./api/use-entity";
export { useAuth } from "./schema/auth/use-auth";
export { Api, type TApiUser, type AuthState, type ApiOptions } from "../../Api";
export { FetchPromise } from "modules/ModuleApi";
export type { RepoQueryIn } from "data";
+19 -13
View File
@@ -1,3 +1,4 @@
import type { DB } from "core";
import {
type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
@@ -23,6 +24,8 @@ export type FileState = {
progress: number;
};
export type FileStateWithData = FileState & { data: DB["media"] };
export type DropzoneRenderProps = {
wrapperRef: RefObject<HTMLDivElement>;
inputProps: ComponentPropsWithRef<"input">;
@@ -50,7 +53,7 @@ export type DropzoneProps = {
autoUpload?: boolean;
onRejected?: (files: FileWithPath[]) => void;
onDeleted?: (file: FileState) => void;
onUploaded?: (files: FileState[]) => void;
onUploaded?: (files: FileStateWithData[]) => void;
placeholder?: {
show?: boolean;
text?: string;
@@ -172,15 +175,16 @@ export function Dropzone({
setUploading(false);
return;
} else {
const uploaded: FileStateWithData[] = [];
for (const file of pendingFiles) {
try {
await uploadFileProgress(file);
uploaded.push(await uploadFileProgress(file));
} catch (e) {
handleUploadError(e);
}
}
setUploading(false);
onUploaded?.(files);
onUploaded?.(uploaded);
}
})();
}
@@ -220,8 +224,8 @@ export function Dropzone({
setFiles((prev) => prev.filter((f) => f.path !== path));
}
function uploadFileProgress(file: FileState) {
return new Promise<void>((resolve, reject) => {
function uploadFileProgress(file: FileState): Promise<FileStateWithData> {
return new Promise((resolve, reject) => {
if (!file.body) {
console.error("File has no body");
reject();
@@ -279,17 +283,19 @@ export function Dropzone({
const response = JSON.parse(xhr.responseText);
console.log("Response:", file, response);
console.log("New state", response.state);
replaceFileState(file.path, {
const newState = {
...response.state,
progress: 1,
state: "uploaded"
});
};
replaceFileState(file.path, newState);
resolve({ ...response, ...file, ...newState });
} catch (e) {
setFileState(file.path, "uploaded", 1);
console.error("Error parsing response", e);
reject(e);
}
resolve();
} else {
setFileState(file.path, "failed", 1);
console.error("Upload failed with status: ", xhr.status, xhr.statusText);
@@ -327,8 +333,8 @@ export function Dropzone({
}
async function uploadFile(file: FileState) {
await uploadFileProgress(file);
onUploaded?.([file]);
const result = await uploadFileProgress(file);
onUploaded?.([result]);
}
const openFileInput = () => inputRef.current?.click();
@@ -496,9 +502,9 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
/>
</div>
<div className="flex flex-col px-1.5 py-1">
<p className="truncate">{file.name}</p>
<p className="truncate select-text">{file.name}</p>
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
<span className="truncate">{file.type}</span>
<span className="truncate select-text">{file.type}</span>
<span>{(file.size / 1024).toFixed(1)} KB</span>
</div>
</div>
@@ -10,7 +10,7 @@ import { mediaItemsToFileStates } from "./helper";
export type DropzoneContainerProps = {
children?: ReactNode;
initialItems?: MediaFieldSchema[];
initialItems?: MediaFieldSchema[] | false;
entity?: {
name: string;
id: number;
@@ -18,6 +18,7 @@ export type DropzoneContainerProps = {
};
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
query?: RepoQueryIn;
randomFilename?: boolean;
} & Omit<Partial<DropzoneProps>, "children" | "initialItems">;
const DropzoneContainerContext = createContext<DropzoneRenderProps>(undefined!);
@@ -28,6 +29,7 @@ export function DropzoneContainer({
entity,
query,
children,
randomFilename,
...props
}: DropzoneContainerProps) {
const id = useId();
@@ -57,12 +59,12 @@ export function DropzoneContainer({
...query
});
const $q = useApiQuery(selectApi, { enabled: !initialItems });
const $q = useApiQuery(selectApi, { enabled: initialItems !== false && !initialItems });
const getUploadInfo = useEvent((file) => {
const url = entity
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
: api.media.getFileUploadUrl(file);
: api.media.getFileUploadUrl(randomFilename ? undefined : file);
return {
url,
@@ -79,7 +81,7 @@ export function DropzoneContainer({
return api.media.deleteFile(file.path);
});
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
const actualItems = (initialItems || $q.data || []) as MediaFieldSchema[];
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
const key = id + JSON.stringify(_initialItems);
+1
View File
@@ -12,6 +12,7 @@ export { useDropzone as useMediaDropzone };
export type {
PreviewComponentProps,
FileState,
FileStateWithData,
DropzoneProps,
DropzoneRenderProps
} from "./Dropzone";
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
}
+1 -1
View File
@@ -33,6 +33,6 @@
"bknd": ["./src/*"]
}
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"include": ["./src/**/*.ts", "./src/**/*.tsx", "vite.dev.ts", "build.ts"],
"exclude": ["node_modules", "dist", "dist/types", "**/*.d.ts"]
}
+4 -3
View File
@@ -29,7 +29,7 @@ if (example) {
let app: App;
const recreate = import.meta.env.VITE_APP_DISABLE_FRESH !== "1";
let routesShown = false;
let firstStart = true;
export default {
async fetch(request: Request) {
if (!app || recreate) {
@@ -48,8 +48,9 @@ export default {
await app.build();
// log routes
if (!routesShown) {
routesShown = true;
if (firstStart) {
console.log("[DB]", credentials);
firstStart = false;
console.log("\n\n[APP ROUTES]");
showRoutes(app.server);
console.log("-------\n\n");
+15 -1
View File
@@ -5,7 +5,19 @@ description: 'Run bknd inside Astro'
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
To get started with Astro and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter:
<Tabs>
<Tab title="CLI Starter">
Create a new Astro CLI starter project by running the following command:
```sh
npx bknd create -i astro
```
</Tab>
<Tab title="Manual">
Create a new Astro project by following the [official guide](https://docs.astro.build/en/install-and-setup/), and then install bknd as a dependency:
<InstallBknd />
<Note>The guide below assumes you're using Astro v4. We've experienced issues with Astro DB
@@ -33,6 +45,8 @@ export default defineConfig({
statically using Astro's middleware. In case you're interested in this, feel free to reach
out in [Discord](https://discord.gg/952SFk8Tb8) or open an [issue on GitHub](https://github.com/bknd-io/bknd/issues/new).
</Note>
</Tab>
</Tabs>
## Serve the API
Create a new catch-all route at `src/pages/api/[...api].ts`:
+16 -1
View File
@@ -5,8 +5,23 @@ description: 'Run bknd inside Bun'
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
To get started with Bun and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter:
<Tabs>
<Tab title="CLI Starter">
Create a new Bun CLI starter project by running the following command:
```sh
npx bknd create -i bun
```
</Tab>
<Tab title="Manual">
Create a new Bun project and then install bknd as a dependency:
<InstallBknd />
</Tab>
</Tabs>
## Serve the API & static files
The `serve` function of the Bun adapter makes sure to also serve the static files required for
+38 -24
View File
@@ -5,30 +5,51 @@ description: 'Run bknd inside Cloudflare Worker'
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Create a new cloudflare worker project by following the
[official guide](https://developers.cloudflare.com/workers/get-started/guide/),
and then install bknd as a dependency:
To get started with Cloudflare Workers and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter:
<Tabs>
<Tab title="CLI Starter">
Create a new Cloudflare CLI starter project by running the following command:
```sh
npx bknd create -i cloudflare
```
</Tab>
<Tab title="Manual">
Create a new cloudflare worker project by following the [official guide](https://developers.cloudflare.com/workers/get-started/guide/), and then install bknd as a dependency:
<InstallBknd />
</Tab>
</Tabs>
## Serve the API
If you don't choose anything specific, the following code will use the `warm` mode. See the
If you don't choose anything specific, the following code will use the `warm` mode and uses the first D1 binding it finds. See the
chapter [Using a different mode](#using-a-different-mode) for available modes.
```ts src/index.ts
import { serve } from "bknd/adapter/cloudflare";
import { serve, d1 } from "bknd/adapter/cloudflare";
// scans your environment for the first D1 binding it finds
export default serve();
// manually specifying a D1 binding:
export default serve<Env>({
app: ({ env }) => ({
connection: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
})
app: ({ env }) => d1({ binding: env.D1_BINDING })
});
// or specify binding using `bindings`
export default serve<Env>({
bindings: ({ env }) => ({ db: env.D1_BINDING })
});
// or use LibSQL
export default serve<Env>({
app: ({ env }) => ({ url: env.DB_URL })
});
```
For more information about the connection object, refer to the [Database](/usage/database) guide.
For more information about the connection object when using LibSQL, refer to the [Database](/usage/database) guide.
Now run the worker:
```bash
@@ -73,24 +94,17 @@ Now in order to also server the static admin files, you have to modify the `wran
## Adding custom routes
You can also add custom routes by defining them after the app has been built, like so:
```ts {14-16}
```ts {5-7}
import { serve } from "bknd/adapter/cloudflare";
import manifest from "__STATIC_CONTENT_MANIFEST";
export default serve<Env>({
app: ({ env }) => ({
connection: {
url: env.DB_URL,
authToken: env.DB_TOKEN
}
}),
// ...
onBuilt: async (app) => {
app.modules.server.get("/hello", (c) => c.json({ hello: "world" }));
},
manifest,
setAdminHtml: true
app.server.get("/hello", (c) => c.json({ hello: "world" }));
}
});
```
The property `app.server` is a [Hono](https://hono.dev/) instance, you can literally anything you can do with Hono.
## Using a different mode
With the Cloudflare Workers adapter, you're being offered to 4 modes to choose from (default:
+15 -1
View File
@@ -5,8 +5,22 @@ description: 'Run bknd inside Next.js'
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
To get started with Next.js and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter:
<Tabs>
<Tab title="CLI Starter">
Create a new Next.js CLI starter project by running the following command:
```sh
npx bknd create -i nextjs
```
</Tab>
<Tab title="Manual">
Create a new Next.js project by following the [official guide](https://nextjs.org/docs/pages/api-reference/cli/create-next-app), and then install bknd as a dependency:
<InstallBknd />
</Tab>
</Tabs>
## Serve the API
``` tsx
+17 -2
View File
@@ -4,9 +4,24 @@ description: 'Run bknd inside Node'
---
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
To get started with Node and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter:
<Tabs>
<Tab title="CLI Starter">
Create a new Node CLI starter project by running the following command:
```sh
npx bknd create -i node
```
</Tab>
<Tab title="Manual">
Create a new Node project and then install bknd as a dependency:
<InstallBknd />
</Tab>
</Tabs>
## Serve the API & static files
The `serve` function of the Node adapter makes sure to also serve the static files required for
@@ -20,7 +35,7 @@ import { serve } from "bknd/adapter/node";
/** @type {import("bknd/adapter/node").NodeAdapterOptions} */
const config = {
connection: {
url: ":memory:"
url: "file:data.db"
}
};
+16 -1
View File
@@ -5,8 +5,23 @@ description: 'Run bknd inside Remix'
import InstallBknd from '/snippets/install-bknd.mdx';
## Installation
Install bknd as a dependency:
To get started with Remix and bknd you can either install the package manually, and follow the descriptions below, or use the CLI starter:
<Tabs>
<Tab title="CLI Starter">
Create a new Remix CLI starter project by running the following command:
```sh
npx bknd create -i remix
```
</Tab>
<Tab title="Manual">
Create a new Remix project by following the [official guide](https://remix.run/docs/en/main/other-api/create-remix), and then install bknd as a dependency:
<InstallBknd />
</Tab>
</Tabs>
## Serve the API
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`: