diff --git a/README.md b/README.md index fb5e81f..e2add07 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index e332439..5d81e77 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -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"); diff --git a/app/__test__/media/mime-types.spec.ts b/app/__test__/media/mime-types.spec.ts index f00016a..55469ff 100644 --- a/app/__test__/media/mime-types.spec.ts +++ b/app/__test__/media/mime-types.spec.ts @@ -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"); + }); }); diff --git a/app/package.json b/app/package.json index 791ea86..002ae21 100644 --- a/app/package.json +++ b/app/package.json @@ -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 ./", diff --git a/app/src/App.ts b/app/src/App.ts index 06917d0..868b11a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -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"; diff --git a/app/src/data/api/DataController.ts b/app/src/data/api/DataController.ts index 1a44912..a315177 100644 --- a/app/src/data/api/DataController.ts +++ b/app/src/data/api/DataController.ts @@ -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 }); } diff --git a/app/src/media/AppMedia.ts b/app/src/media/AppMedia.ts index b2040ce..f1736d4 100644 --- a/app/src/media/AppMedia.ts +++ b/app/src/media/AppMedia.ts @@ -124,8 +124,9 @@ export class AppMedia extends Module { 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" } ); diff --git a/app/src/media/api/MediaApi.ts b/app/src/media/api/MediaApi.ts index bf3277b..c139435 100644 --- a/app/src/media/api/MediaApi.ts +++ b/app/src/media/api/MediaApi.ts @@ -44,7 +44,8 @@ export class MediaApi extends ModuleApi { 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}`); } diff --git a/app/src/media/api/MediaController.ts b/app/src/media/api/MediaController.ts index 4194b2b..9758fcf 100644 --- a/app/src/media/api/MediaController.ts +++ b/app/src/media/api/MediaController.ts @@ -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); diff --git a/app/src/media/storage/Storage.ts b/app/src/media/storage/Storage.ts index 24be8f7..6226cf9 100644 --- a/app/src/media/storage/Storage.ts +++ b/app/src/media/storage/Storage.ts @@ -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; diff --git a/app/src/media/storage/events/index.ts b/app/src/media/storage/events/index.ts index c285600..0b63cd4 100644 --- a/app/src/media/storage/events/index.ts +++ b/app/src/media/storage/events/index.ts @@ -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 { +export class FileUploadedEvent extends Event { 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 }> { diff --git a/app/src/media/storage/mime-types-tiny.ts b/app/src/media/storage/mime-types-tiny.ts index f54b51e..1f90f77 100644 --- a/app/src/media/storage/mime-types-tiny.ts +++ b/app/src/media/storage/mime-types-tiny.ts @@ -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; diff --git a/app/src/media/utils/index.ts b/app/src/media/utils/index.ts index c042acc..e71e2b7 100644 --- a/app/src/media/utils/index.ts +++ b/app/src/media/utils/index.ts @@ -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"); } + 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), getExtension(filename)].filter(Boolean).join("."); + return [randomString(length), ext].filter(Boolean).join("."); } diff --git a/app/src/ui/client/index.ts b/app/src/ui/client/index.ts index 0d9711c..30b1e21 100644 --- a/app/src/ui/client/index.ts +++ b/app/src/ui/client/index.ts @@ -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"; diff --git a/app/src/ui/elements/media/Dropzone.tsx b/app/src/ui/elements/media/Dropzone.tsx index 537cfe0..2c2b39d 100644 --- a/app/src/ui/elements/media/Dropzone.tsx +++ b/app/src/ui/elements/media/Dropzone.tsx @@ -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; 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((resolve, reject) => { + function uploadFileProgress(file: FileState): Promise { + 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 = ({ file, handleUpload, handleDelete }) = />
-

{file.name}

+

{file.name}

- {file.type} + {file.type} {(file.size / 1024).toFixed(1)} KB
diff --git a/app/src/ui/elements/media/DropzoneContainer.tsx b/app/src/ui/elements/media/DropzoneContainer.tsx index ba140ed..ebb4a83 100644 --- a/app/src/ui/elements/media/DropzoneContainer.tsx +++ b/app/src/ui/elements/media/DropzoneContainer.tsx @@ -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; query?: RepoQueryIn; + randomFilename?: boolean; } & Omit, "children" | "initialItems">; const DropzoneContainerContext = createContext(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); diff --git a/app/src/ui/elements/media/index.ts b/app/src/ui/elements/media/index.ts index ff5c8f8..b0f64e6 100644 --- a/app/src/ui/elements/media/index.ts +++ b/app/src/ui/elements/media/index.ts @@ -12,6 +12,7 @@ export { useDropzone as useMediaDropzone }; export type { PreviewComponentProps, FileState, + FileStateWithData, DropzoneProps, DropzoneRenderProps } from "./Dropzone"; diff --git a/app/tsconfig.build.json b/app/tsconfig.build.json new file mode 100644 index 0000000..5f1c8af --- /dev/null +++ b/app/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/app/tsconfig.json b/app/tsconfig.json index 8be6838..52a41a4 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -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"] } \ No newline at end of file diff --git a/app/vite.dev.ts b/app/vite.dev.ts index cf95359..3935511 100644 --- a/app/vite.dev.ts +++ b/app/vite.dev.ts @@ -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");