Compare commits

...

115 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
dswbx 89871a8ef7 Merge pull request #75 from bknd-io/release/0.8
Release 0.8
2025-02-19 19:06:16 +01:00
dswbx 7f0f6bec08 bump version 0.8.0 2025-02-19 19:05:29 +01:00
dswbx 8e1ad98964 Merge pull request #88 from bknd-io/refactor/improve-remix-example
refactor/improve-remix-example
2025-02-19 17:44:10 +01:00
dswbx a29e824ecc minor fixes in build and console 2025-02-19 17:41:48 +01:00
dswbx b436758fe6 updated remix integration docs 2025-02-19 14:28:22 +01:00
dswbx ee7dcd44f4 updated remix adapter for non-middleware env, fixed console mute 2025-02-19 14:13:29 +01:00
dswbx f2e5815e24 adjusted remix example 2025-02-19 14:12:40 +01:00
dswbx 335fe42a22 adjusted nextjs example theme 2025-02-18 20:14:08 +01:00
dswbx a01b99be27 updated App options "seed", changed examples to file db 2025-02-18 20:01:08 +01:00
dswbx a4e46a2768 Merge pull request #87 from bknd-io/fix/many-to-many
fix persisting of many to many entity
2025-02-18 19:03:56 +01:00
dswbx f494735a79 fix persisting of many to many entity 2025-02-18 15:43:19 +01:00
dswbx 400db84dd5 change default mode of cloudflare example to "warm", suppress listener info 2025-02-18 13:26:26 +01:00
dswbx 964bacd824 updated examples package.json's and README.md's 2025-02-18 13:19:35 +01:00
dswbx 7994eabe7d export RepoQueryIn from client 2025-02-18 12:54:13 +01:00
dswbx dec382b49d cli create: add default database name for cloudflare 2025-02-18 10:49:37 +01:00
dswbx 93cee4f8aa Merge pull request #83 from bknd-io/refactor/data-api-entity-prefix
prefixed data entity endpoints with /entity
2025-02-18 10:22:31 +01:00
dswbx e99abb09b6 Merge branch 'release/0.8' into refactor/data-api-entity-prefix 2025-02-18 10:22:15 +01:00
dswbx a5ab102b34 Merge pull request #86 from bknd-io/feat/cli-prefer-file
cli run will prefer data file now, improved console coloring
2025-02-18 10:21:34 +01:00
dswbx 1ca6c5b43d Merge pull request #85 from bknd-io/refactor/app-options-manager
refactor app constructor params to move manager into app options
2025-02-18 10:21:27 +01:00
dswbx 64658184ac Merge pull request #84 from bknd-io/feat/debug-routes
added deboug routes cli action
2025-02-18 10:21:17 +01:00
dswbx 61ad4a3109 Merge pull request #82 from bknd-io/feat/ui-admin-theme
add admin "system" theme option that checks user prefs
2025-02-18 10:21:02 +01:00
dswbx e12ed1e4b3 cli run will prefer data file now, improved console coloring 2025-02-18 10:11:59 +01:00
dswbx e103d3ac33 refactor app constructor params to move manager into app options 2025-02-18 09:36:59 +01:00
dswbx e2217c411a added deboug routes cli action 2025-02-18 09:25:34 +01:00
dswbx 3e6d381239 prefixed data entity endpoints with /entity 2025-02-18 09:12:12 +01:00
dswbx 0823b182f6 admin: added "system" theme option, check user pref 2025-02-18 08:20:41 +01:00
dswbx bd362607ae cli create: fix cloudflare overriding name in wrangler 2025-02-17 20:46:20 +01:00
dswbx 61ac31eba9 Merge pull request #81 from bknd-io/feat/app-auth-context
feat/app-auth-context
2025-02-14 20:33:12 +01:00
dswbx 21a8f56c48 moved auth ctx to request context, cleaned up system controller 2025-02-14 20:32:09 +01:00
dswbx fb76f8d789 improve media mime type inferring + added uploadToEntity in media api 2025-02-14 18:02:43 +01:00
dswbx 4bd201d454 cli create: updated templates, reworked examples to be standalone 2025-02-14 14:56:35 +01:00
dswbx b412da836c cli create: fix installing deps spinner 2025-02-14 14:08:15 +01:00
dswbx 425465becd Merge pull request #79 from bknd-io/feat/cli-starters
added cli-starters
2025-02-14 13:57:48 +01:00
dswbx 3b487ade2a finalize initial starters 2025-02-14 13:55:46 +01:00
dswbx 7d3c76d7c1 Merge remote-tracking branch 'origin/release/0.8' into feat/cli-starters 2025-02-14 11:54:15 +01:00
dswbx fd297c6c50 bump rc version 2025-02-14 11:48:53 +01:00
dswbx 149a6d768c Merge pull request #80 from bknd-io/fix/api-and-media
fix/api-and-media
2025-02-14 11:42:50 +01:00
dswbx 24e69eec90 fix s3 adapter 2025-02-14 11:42:26 +01:00
dswbx 9c38c8f2ad improved Dropzone error handling 2025-02-14 11:21:11 +01:00
dswbx f0bdb3a75e improved typing, renamed functions for clarity 2025-02-14 11:04:50 +01:00
dswbx c4e505582b updated media api and added tests, fixed body limit 2025-02-14 10:42:36 +01:00
dswbx cc938db4b8 add default fetcher fallback for ModuleApi 2025-02-13 11:24:35 +01:00
dswbx 68277a68d5 cloudflare update assets dir 2025-02-13 09:12:08 +01:00
dswbx 8e72b5b615 init create cli, added node and partially cloudflare 2025-02-13 08:51:48 +01:00
dswbx 07422f30ba Merge pull request #78 from bknd-io/feat/improve-cli-with-env
cli: fallback to env if present
2025-02-12 09:54:29 +01:00
dswbx d84731d89e cli: try to fallback to env if present 2025-02-12 09:52:18 +01:00
dswbx 4afea14893 Merge pull request #77 from bknd-io/feat/conn-remove-type
update app create config to accept libsql config directly
2025-02-12 09:39:16 +01:00
dswbx 2c6a4d2bed update app create config to accept libsql config directly 2025-02-12 09:21:51 +01:00
dswbx f466dd166d Merge pull request #73 from bknd-io/feat/cf-d1-and-r2
adding d1 and r2 for cloudflare environments
2025-02-12 09:04:47 +01:00
dswbx 6d8f86b2c9 Merge pull request #76 from bknd-io/feat/mm-revert-on-error
run schema mutations in safe proxy and revert to previous on error
2025-02-12 09:04:24 +01:00
dswbx c8fa704e32 running schema mutations in safe proxy and revert to previous on error 2025-02-12 09:01:56 +01:00
dswbx 61d06973a4 ignore d1 system tables, deploy verified 2025-02-12 07:29:56 +01:00
dswbx 7a6321f4ce added r2 binding support to cf adapter 2025-02-11 10:04:53 +01:00
dswbx 4dde67ca21 update README package sizes 2025-02-10 17:56:08 +01:00
dswbx f9c612e0fa Merge pull request #74 from bknd-io/fix/ui-modal-and-media-reload
fixing ui modal styling and make sure schema reloads on media setting changes
2025-02-10 17:49:53 +01:00
dswbx d7e772efd9 fixing ui modal styling and make sure schema reloads on media setting changes 2025-02-10 17:47:56 +01:00
dswbx be39d1c374 adding d1 for cloudflare environments 2025-02-10 17:46:22 +01:00
dswbx 1e5c0dbc22 Merge pull request #72 from bknd-io/fix/0.7.1
fix: popover regression & dropzone rerender lost state
2025-02-10 14:53:14 +01:00
dswbx d79da9d204 fix: popover regression & dropzone rerender lost state 2025-02-10 14:52:14 +01:00
dswbx 09d35d3cc0 Merge pull request #60 from bknd-io/release/0.7
Release 0.7
2025-02-08 16:38:07 +01:00
dswbx f7e6928dba form: fix popover, improve form types 2025-02-08 15:43:02 +01:00
dswbx 3fa682bfe1 form: fix switch required, add root form error to media settings 2025-02-08 13:27:47 +01:00
dswbx 97b0ff24c8 Merge pull request #71 from bknd-io/feat/elements-native-form
feat/elements-native-form
2025-02-08 12:01:35 +01:00
dswbx bb964dc835 added native reactive form to elements 2025-02-08 11:59:43 +01:00
dswbx 5840ec6b89 Merge pull request #70 from bknd-io/feat/optimize-form-renders
Feat/optimize form renders
2025-02-08 11:59:13 +01:00
dswbx f29641c702 using path instead of pointer, replaced lodash usage 2025-02-08 09:18:24 +01:00
dswbx 2e3ee65aa7 added more input field types and improved typing 2025-02-07 17:28:01 +01:00
dswbx 11a6f5c9b9 hide rerender scanner 2025-02-07 16:12:39 +01:00
dswbx 324d641410 form rerenders optimized 2025-02-07 16:11:21 +01:00
dswbx 02e7e1ca95 initially reduced form rerenders 2025-02-06 17:12:43 +01:00
dswbx 78d4f928ba fix nextjs node adapter import 2025-02-06 15:19:25 +01:00
dswbx 0a5cc2ad98 fix bknd theme 2025-02-06 15:00:54 +01:00
dswbx e14d528386 Merge pull request #68 from bknd-io/feat/media-settings-ui
Improved media settings UI/UX
2025-02-06 13:59:24 +01:00
dswbx 15003b004b Merge pull request #67 from bknd-io/fix/api-query-with-string-object
fix/api-query-with-string-object
2025-02-06 13:58:50 +01:00
dswbx 9a4c2bd530 finalize new media settings ui 2025-02-06 13:58:29 +01:00
dswbx ab61a1392c Merge pull request #69 from bknd-io/fix/admin-config-secrets-missing
fix clearing schema on unauthorized secrets fetching
2025-02-06 09:01:47 +01:00
dswbx 53d55448e6 fix clearing schema on unauthorized secrets fetching 2025-02-06 09:01:11 +01:00
dswbx 46cf310ad6 finalize new media settings 2025-02-05 17:54:48 +01:00
dswbx 8418231c43 improved media settings implementation 2025-02-05 16:11:53 +01:00
dswbx 4b3493a6f5 changed media settings to new form 2025-02-05 11:04:37 +01:00
dswbx f432473ed9 initial json schema form implementation 2025-02-05 10:31:08 +01:00
dswbx 8f0cef1f37 clear console log 2025-02-01 12:15:48 +01:00
dswbx a8e78f984d fix repo stringified string parsing 2025-02-01 12:12:22 +01:00
dswbx babeaae8d1 Merge pull request #66 from bknd-io/fix/auth-api-include-cookie
also sending cookies on json auth requests
2025-02-01 09:15:16 +01:00
dswbx 45d44f8177 Merge remote-tracking branch 'origin/release/0.7' into fix/auth-api-include-cookie
# Conflicts:
#	app/src/auth/authenticate/Authenticator.ts
2025-02-01 09:15:01 +01:00
dswbx db476080ad also sending cookies on json auth requests 2025-02-01 09:13:42 +01:00
dswbx 158818520b Merge pull request #65 from bknd-io/feat/auth-redirect-param
feat/auth-redirect-param
2025-01-29 20:30:52 +01:00
dswbx 4755288fed improve fallbacks for redirection 2025-01-29 20:30:27 +01:00
dswbx 10db267164 added a redirect param to password strategy 2025-01-29 20:26:06 +01:00
dswbx dfe7d55057 Merge pull request #64 from bknd-io/feat/improved-api-construction
updated API instantiation, and update user on verify
2025-01-29 14:49:00 +01:00
dswbx c2b3316fcb updated API instantiation, and update user on verify 2025-01-29 14:44:32 +01:00
dswbx 86ba055f5e fix entity table select 2025-01-28 09:10:38 +01:00
dswbx 9d6ccda20d Merge pull request #63 from bknd-io/fix/api-and-adapter
fix/api-and-adapter
2025-01-28 08:16:56 +01:00
dswbx 39c419c320 add built state and skip auth cookie if not signed in for local calls 2025-01-25 19:30:03 +01:00
dswbx 2d6d83ccb2 add fetcher option to api to allow local calls 2025-01-25 19:29:36 +01:00
dswbx ba1eb5fe08 remove adapter re-exports 2025-01-25 19:28:39 +01:00
dswbx 123389786d bump 0.7 rc version 2025-01-25 14:19:23 +01:00
dswbx b65bb3b826 Merge pull request #62 from bknd-io/feat/adapter-optimize-imports
optimize adapter imports to deduplicate
2025-01-25 14:14:25 +01:00
dswbx 5b40d5eaf4 optimize adapter imports to deduplicate 2025-01-25 14:13:34 +01:00
dswbx 89b1fd3909 Merge pull request #61 from bknd-io/feat/cf-static-assets
adapter(cloudflare): add supports for the new assets feature
2025-01-25 13:48:52 +01:00
dswbx aa7eac258b adapter(cloudflare): add supports for the new assets feature 2025-01-25 13:40:28 +01:00
dswbx 392a06c486 Merge pull request #59 from bknd-io/feat/adapter-auth-env
Adapter: verified auth & env-specific construction
2025-01-25 13:36:59 +01:00
dswbx 1beac5043b updated cloudflare adapter docs to match new args 2025-01-25 12:33:05 +01:00
dswbx 475488c910 Merge remote-tracking branch 'origin/feat/adapter-api-async' into feat/adapter-auth-env 2025-01-25 11:55:15 +01:00
dswbx 9ddacb7ae5 added adapter exports, generalized env-depending config construction 2025-01-25 11:54:19 +01:00
dswbx b0c5f6307a updated adapters to automatically verify auth 2025-01-25 09:09:09 +01:00
215 changed files with 7303 additions and 2045 deletions
+1
View File
@@ -19,6 +19,7 @@ packages/media/.env
.history .history
**/*/.db/* **/*/.db/*
**/*/.configs/* **/*/.configs/*
**/*/.template/*
**/*/*.db **/*/*.db
**/*/*.db-shm **/*/*.db-shm
**/*/*.db-wal **/*/*.db-wal
+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. > and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
## Size ## Size
![gzipped size of bknd](https://img.badgesize.io/https://unpkg.com/bknd@0.6.1/dist/index.js?compression=gzip&label=bknd) ![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.6.1/dist/ui/client/index.js?compression=gzip&label=bknd/client) ![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.6.1/dist/ui/elements/index.js?compression=gzip&label=bknd/elements) ![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.6.1/dist/ui/index.js?compression=gzip&label=bknd/ui) ![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. 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
@@ -0,0 +1 @@
tmp/*
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "bun:test";
import { sign } from "hono/jwt";
import { Api } from "../../src/Api";
describe("Api", async () => {
it("should construct without options", () => {
const api = new Api();
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should ignore force verify if no claims given", () => {
const api = new Api({ verified: true });
expect(api.baseUrl).toBe("http://localhost");
expect(api.isAuthVerified()).toBe(false);
});
it("should construct from request (token)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Authorization: `Bearer ${token}`
}
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://example.com");
});
it("should construct from request (cookie)", async () => {
const token = await sign({ foo: "bar" }, "1234");
const request = new Request("http://example.com/test", {
headers: {
Cookie: `auth=${token}`
}
});
const api = new Api({ request });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
console.log(params);
expect(params.token).toBe(token);
expect(params.token_transport).toBe("cookie");
expect(params.host).toBe("http://example.com");
});
it("should construct from token", async () => {
const token = await sign({ foo: "bar" }, "1234");
const api = new Api({ token });
expect(api.isAuthVerified()).toBe(false);
const params = api.getParams();
expect(params.token).toBe(token);
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://localhost");
});
it("should prefer host when request is given", async () => {
const request = new Request("http://example.com/test");
const api = new Api({ request, host: "http://another.com" });
const params = api.getParams();
expect(params.token).toBeUndefined();
expect(params.token_transport).toBe("header");
expect(params.host).toBe("http://another.com");
});
});
+3 -3
View File
@@ -17,11 +17,11 @@ describe("DataApi", () => {
const get = api.readMany("a".repeat(300), { select: ["id", "name"] }); const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
expect(get.request.method).toBe("GET"); expect(get.request.method).toBe("GET");
expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`); expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`);
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] }); const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
expect(post.request.method).toBe("POST"); expect(post.request.method).toBe("POST");
expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`); expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`);
}); });
it("returns result", async () => { it("returns result", async () => {
@@ -39,7 +39,7 @@ describe("DataApi", () => {
const app = controller.getController(); const app = controller.getController();
{ {
const res = (await app.request("/posts")) as Response; const res = (await app.request("/entity/posts")) as Response;
const { data } = await res.json(); const { data } = await res.json();
expect(data.length).toEqual(3); expect(data.length).toEqual(3);
} }
+146
View File
@@ -0,0 +1,146 @@
/// <reference types="@types/bun" />
import { describe, expect, it } from "bun:test";
import { Hono } from "hono";
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
import { MediaApi } from "../../src/media/api/MediaApi";
import { assetsPath, assetsTmpPath } from "../helper";
const mockedBackend = new Hono()
.basePath("/api/media")
.post("/upload/:name", async (c) => {
const { name } = c.req.param();
const body = await getFileFromContext(c);
return c.json({ name, is_file: isFile(body), size: body.size });
})
.get("/file/:name", async (c) => {
const { name } = c.req.param();
const file = Bun.file(`${assetsPath}/${name}`);
return new Response(file, {
headers: {
"Content-Type": file.type,
"Content-Length": file.size.toString()
}
});
});
describe("MediaApi", () => {
it("should give correct file upload url", () => {
const host = "http://localhost";
const basepath = "/api/media";
// @ts-ignore tests
const api = new MediaApi({
host,
basepath
});
expect(api.getFileUploadUrl({ path: "path" })).toBe(`${host}${basepath}/upload/path`);
});
it("should have correct upload headers", () => {
// @ts-ignore tests
const api = new MediaApi({
token: "token"
});
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
});
it("should get file: native", async () => {
const name = "image.png";
const path = `${assetsTmpPath}/${name}`;
const res = await mockedBackend.request("/api/media/file/" + name);
await Bun.write(path, res);
const file = await Bun.file(path);
expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png");
await file.delete();
});
it("download", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const file = await api.download(name);
expect(isFile(file)).toBe(true);
expect(file.size).toBeGreaterThan(0);
expect(file.type).toBe("image/png");
expect(file.name).toContain(name);
});
it("getFile", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const res = await api.getFile(name);
expect(res.ok).toBe(true);
// make sure it's a normal api request as usual
expect(res.res.ok).toBe(true);
expect(isReadableStream(res)).toBe(true);
expect(isReadableStream(res.body)).toBe(true);
expect(isReadableStream(res.res.body)).toBe(true);
const blob = await res.res.blob();
expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png");
expect(blob.name).toContain(name);
});
it("getFileStream", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const name = "image.png";
const res = await api.getFileStream(name);
expect(isReadableStream(res)).toBe(true);
const blob = await new Response(res).blob();
expect(isFile(blob)).toBe(true);
expect(blob.size).toBeGreaterThan(0);
expect(blob.type).toBe("image/png");
expect(blob.name).toContain(name);
});
it("should upload file in various ways", async () => {
// @ts-ignore tests
const api = new MediaApi({}, mockedBackend.request);
const file = Bun.file(`${assetsPath}/image.png`);
async function matches(req: Promise<any>, filename: string) {
const res: any = await req;
expect(res.name).toBe(filename);
expect(res.is_file).toBe(true);
expect(res.size).toBe(file.size);
}
const url = "http://localhost/api/media/file/image.png";
// upload bun file
await matches(api.upload(file as any, { filename: "bunfile.png" }), "bunfile.png");
// upload via request
await matches(api.upload(new Request(url), { filename: "request.png" }), "request.png");
// upload via url
await matches(api.upload(url, { filename: "url.png" }), "url.png");
// upload via response
{
const response = await mockedBackend.request(url);
await matches(api.upload(response, { filename: "response.png" }), "response.png");
}
// upload via readable from bun
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
// upload via readable from response
{
const response = (await mockedBackend.request(url)) as Response;
await matches(
await api.upload(response.body!, { filename: "readable.png" }),
"readable.png"
);
}
});
});
+2 -8
View File
@@ -27,10 +27,7 @@ describe("ModuleApi", () => {
it("fetches endpoint", async () => { it("fetches endpoint", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const res = await api.get("/endpoint"); const res = await api.get("/endpoint");
expect(res.res.ok).toEqual(true); expect(res.res.ok).toEqual(true);
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
it("has accessible request", async () => { it("has accessible request", async () => {
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" })); const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
const api = new Api({ host }); const api = new Api({ host }, app.request as typeof fetch);
// @ts-expect-error it's protected
api.fetcher = app.request as typeof fetch;
const promise = api.get("/endpoint"); const promise = api.get("/endpoint");
expect(promise.request).toBeDefined(); expect(promise.request).toBeDefined();
+30
View File
@@ -70,4 +70,34 @@ describe("repros", async () => {
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]); expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
}); });
test.only("verify inversedBy", async () => {
const schema = proto.em(
{
products: proto.entity("products", {
title: proto.text()
}),
product_likes: proto.entity("product_likes", {
created_at: proto.date()
}),
users: proto.entity("users", {})
},
(fns, schema) => {
fns.relation(schema.product_likes).manyToOne(schema.products, { inversedBy: "likes" });
fns.relation(schema.product_likes).manyToOne(schema.users);
}
);
const app = createApp({ initialConfig: { data: schema.toJSON() } });
await app.build();
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
expect(info.fields).toEqual(["id", "title"]);
expect(info.relations.listable).toEqual([
{
entity: "product_likes",
ref: "likes"
}
]);
});
}); });
+8 -12
View File
@@ -16,10 +16,8 @@ describe("authorize", () => {
role: "admin" role: "admin"
}; };
guard.setUserContext(user); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
expect(() => guard.granted("something")).toThrow(); expect(() => guard.granted("something")).toThrow();
}); });
@@ -46,10 +44,8 @@ describe("authorize", () => {
role: "admin" role: "admin"
}; };
guard.setUserContext(user); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write", user)).toBe(true);
expect(guard.granted("read")).toBe(true);
expect(guard.granted("write")).toBe(true);
}); });
test("guard implicit allow", async () => { test("guard implicit allow", async () => {
@@ -66,12 +62,12 @@ describe("authorize", () => {
} }
}); });
guard.setUserContext({ const user = {
role: "admin" role: "admin"
}); };
expect(guard.granted("read")).toBe(true); expect(guard.granted("read", user)).toBe(true);
expect(guard.granted("write")).toBe(true); expect(guard.granted("write", user)).toBe(true);
}); });
test("guard with guest role implicit allow", async () => { test("guard with guest role implicit allow", async () => {
+1
View File
@@ -111,6 +111,7 @@ describe("EventManager", async () => {
emgr.onEvent(ReturnEvent, async () => "1", "sync"); emgr.onEvent(ReturnEvent, async () => "1", "sync");
emgr.onEvent(ReturnEvent, async () => "0", "sync"); emgr.onEvent(ReturnEvent, async () => "0", "sync");
// @todo: fix this
// @ts-expect-error must be string // @ts-expect-error must be string
emgr.onEvent(ReturnEvent, async () => 0, "sync"); emgr.onEvent(ReturnEvent, async () => 0, "sync");
+150 -13
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { Perf } from "../../src/core/utils"; import { Perf, isBlob, ucFirst } from "../../src/core/utils";
import * as reqres from "../../src/core/utils/reqres"; import * as utils from "../../src/core/utils";
import * as strings from "../../src/core/utils/strings";
async function wait(ms: number) { async function wait(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
describe("[core] strings", async () => { describe("[core] strings", async () => {
test("objectToKeyValueArray", async () => { test("objectToKeyValueArray", async () => {
const obj = { a: 1, b: 2, c: 3 }; const obj = { a: 1, b: 2, c: 3 };
const result = strings.objectToKeyValueArray(obj); const result = utils.objectToKeyValueArray(obj);
expect(result).toEqual([ expect(result).toEqual([
{ key: "a", value: 1 }, { key: "a", value: 1 },
{ key: "b", value: 2 }, { key: "b", value: 2 },
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
}); });
test("snakeToPascalWithSpaces", async () => { test("snakeToPascalWithSpaces", async () => {
const result = strings.snakeToPascalWithSpaces("snake_to_pascal"); const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
expect(result).toBe("Snake To Pascal"); expect(result).toBe("Snake To Pascal");
}); });
test("randomString", async () => { test("randomString", async () => {
const result = strings.randomString(10); const result = utils.randomString(10);
expect(result).toHaveLength(10); expect(result).toHaveLength(10);
}); });
test("pascalToKebab", async () => { test("pascalToKebab", async () => {
const result = strings.pascalToKebab("PascalCase"); const result = utils.pascalToKebab("PascalCase");
expect(result).toBe("pascal-case"); expect(result).toBe("pascal-case");
}); });
test("replaceSimplePlaceholders", async () => { test("replaceSimplePlaceholders", async () => {
const str = "Hello, {$name}!"; const str = "Hello, {$name}!";
const vars = { name: "John" }; const vars = { name: "John" };
const result = strings.replaceSimplePlaceholders(str, vars); const result = utils.replaceSimplePlaceholders(str, vars);
expect(result).toBe("Hello, John!"); expect(result).toBe("Hello, John!");
}); });
}); });
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
const headers = new Headers(); const headers = new Headers();
headers.append("Content-Type", "application/json"); headers.append("Content-Type", "application/json");
headers.append("Authorization", "Bearer 123"); headers.append("Authorization", "Bearer 123");
const obj = reqres.headersToObject(headers); const obj = utils.headersToObject(headers);
expect(obj).toEqual({ expect(obj).toEqual({
"content-type": "application/json", "content-type": "application/json",
authorization: "Bearer 123" authorization: "Bearer 123"
@@ -59,23 +58,74 @@ describe("Core Utils", async () => {
test("replaceUrlParam", () => { test("replaceUrlParam", () => {
const url = "/api/:id/:name"; const url = "/api/:id/:name";
const params = { id: "123", name: "test" }; const params = { id: "123", name: "test" };
const result = reqres.replaceUrlParam(url, params); const result = utils.replaceUrlParam(url, params);
expect(result).toBe("/api/123/test"); expect(result).toBe("/api/123/test");
}); });
test("encode", () => { test("encode", () => {
const obj = { id: "123", name: "test" }; const obj = { id: "123", name: "test" };
const result = reqres.encodeSearch(obj); const result = utils.encodeSearch(obj);
expect(result).toBe("id=123&name=test"); expect(result).toBe("id=123&name=test");
const obj2 = { id: "123", name: ["test1", "test2"] }; const obj2 = { id: "123", name: ["test1", "test2"] };
const result2 = reqres.encodeSearch(obj2); const result2 = utils.encodeSearch(obj2);
expect(result2).toBe("id=123&name=test1&name=test2"); expect(result2).toBe("id=123&name=test1&name=test2");
const obj3 = { id: "123", name: { test: "test" } }; const obj3 = { id: "123", name: { test: "test" } };
const result3 = reqres.encodeSearch(obj3, { encode: true }); const result3 = utils.encodeSearch(obj3, { encode: true });
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D"); expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
}); });
describe("guards", () => {
const types = {
blob: new Blob(),
file: new File([""], "file.txt"),
stream: new ReadableStream(),
arrayBuffer: new ArrayBuffer(10),
arrayBufferView: new Uint8Array(new ArrayBuffer(10))
};
const fns = [
[utils.isReadableStream, "stream"],
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
[utils.isArrayBuffer, "arrayBuffer"],
[utils.isArrayBufferView, "arrayBufferView"]
] as const;
const additional = [0, 0.0, "", null, undefined, {}, []];
for (const [fn, type, _to_test] of fns) {
test(`is${ucFirst(type)}`, () => {
const to_test = _to_test ?? (Object.keys(types) as string[]);
for (const key of to_test) {
const value = types[key as keyof typeof types];
const result = fn(value);
expect(result).toBe(key === type);
}
for (const value of additional) {
const result = fn(value);
expect(result).toBe(false);
}
});
}
});
test("getContentName", () => {
const name = "test.json";
const text = "attachment; filename=" + name;
const headers = new Headers({
"Content-Disposition": text
});
const request = new Request("http://example.com", {
headers
});
expect(utils.getContentName(text)).toBe(name);
expect(utils.getContentName(headers)).toBe(name);
expect(utils.getContentName(request)).toBe(name);
});
}); });
describe("perf", async () => { describe("perf", async () => {
@@ -108,4 +158,91 @@ describe("Core Utils", async () => {
expect(count).toBe(2); expect(count).toBe(2);
}); });
}); });
describe("objects", () => {
test("omitKeys", () => {
const objects = [
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
] as [object, string[], object][];
for (const [obj, keys, expected] of objects) {
const result = utils.omitKeys(obj, keys as any);
expect(result).toEqual(expected);
}
});
test("isEqual", () => {
const objects = [
[1, 1, true],
[1, "1", false],
[1, 2, false],
["1", "1", true],
["1", "2", false],
[true, true, true],
[true, false, false],
[false, false, true],
[1, Number.NaN, false],
[Number.NaN, Number.NaN, true],
[null, null, true],
[null, undefined, false],
[undefined, undefined, true],
[new Map([["a", 1]]), new Map([["a", 1]]), true],
[new Map([["a", 1]]), new Map([["a", 2]]), false],
[new Map([["a", 1]]), new Map([["b", 1]]), false],
[
new Map([["a", 1]]),
new Map([
["a", 1],
["b", 2]
]),
false
],
[{ a: 1 }, { a: 1 }, true],
[{ a: 1 }, { a: 2 }, false],
[{ a: 1 }, { b: 1 }, false],
[{ a: "1" }, { a: "1" }, true],
[{ a: "1" }, { a: "2" }, false],
[{ a: "1" }, { b: "1" }, false],
[{ a: 1 }, { a: 1, b: 2 }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
[[1, 2, 3], [1, 2, 3], true],
[[1, 2, 3], [1, 2, 4], false],
[[1, 2, 3], [1, 2, 3, 4], false],
[[{ a: 1 }], [{ a: 1 }], true],
[[{ a: 1 }], [{ a: 2 }], false],
[[{ a: 1 }], [{ b: 1 }], false]
] as [any, any, boolean][];
for (const [a, b, expected] of objects) {
const result = utils.isEqual(a, b);
expect(result).toEqual(expected);
}
});
test("getPath", () => {
const tests = [
[{ a: 1, b: 2, c: 3 }, "a", 1],
[{ a: 1, b: 2, c: 3 }, "b", 2],
[{ a: { b: 1 } }, "a.b", 1],
[{ a: { b: 1 } }, "a.b.c", null, null],
[{ a: { b: 1 } }, "a.b.c", 1, 1],
[[[1]], "0.0", 1]
] as [object, string, any, any][];
for (const [obj, path, expected, defaultValue] of tests) {
const result = utils.getPath(obj, path, defaultValue);
expect(result).toEqual(expected);
}
});
});
}); });
+10 -10
View File
@@ -116,7 +116,7 @@ describe("[data] DataController", async () => {
//console.log("app.routes", app.routes); //console.log("app.routes", app.routes);
// create users // create users
for await (const _user of fixtures.users) { for await (const _user of fixtures.users) {
const res = await app.request("/users", { const res = await app.request("/entity/users", {
method: "POST", method: "POST",
body: JSON.stringify(_user) body: JSON.stringify(_user)
}); });
@@ -131,7 +131,7 @@ describe("[data] DataController", async () => {
// create posts // create posts
for await (const _post of fixtures.posts) { for await (const _post of fixtures.posts) {
const res = await app.request("/posts", { const res = await app.request("/entity/posts", {
method: "POST", method: "POST",
body: JSON.stringify(_post) body: JSON.stringify(_post)
}); });
@@ -145,7 +145,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (read many)", async () => { test("/:entity (read many)", async () => {
const res = await app.request("/users"); const res = await app.request("/entity/users");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3); expect(data.meta.total).toBe(3);
@@ -156,7 +156,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/query (func query)", async () => { test("/:entity/query (func query)", async () => {
const res = await app.request("/users/query", { const res = await app.request("/entity/users/query", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
@@ -175,7 +175,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (read many, paginated)", async () => { test("/:entity (read many, paginated)", async () => {
const res = await app.request("/users?limit=1&offset=2"); const res = await app.request("/entity/users?limit=1&offset=2");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResponse;
expect(data.meta.total).toBe(3); expect(data.meta.total).toBe(3);
@@ -186,7 +186,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/:id (read one)", async () => { test("/:entity/:id (read one)", async () => {
const res = await app.request("/users/3"); const res = await app.request("/entity/users/3");
const data = (await res.json()) as RepositoryResponse<EntityData>; const data = (await res.json()) as RepositoryResponse<EntityData>;
console.log("data", data); console.log("data", data);
@@ -197,7 +197,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity (update one)", async () => { test("/:entity (update one)", async () => {
const res = await app.request("/users/3", { const res = await app.request("/entity/users/3", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name: "new name" }) body: JSON.stringify({ name: "new name" })
}); });
@@ -208,7 +208,7 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/:id/:reference (read references)", async () => { test("/:entity/:id/:reference (read references)", async () => {
const res = await app.request("/users/1/posts"); const res = await app.request("/entity/users/1/posts");
const data = (await res.json()) as RepositoryResponse; const data = (await res.json()) as RepositoryResponse;
console.log("data", data); console.log("data", data);
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
}); });
test("/:entity/:id (delete one)", async () => { test("/:entity/:id (delete one)", async () => {
const res = await app.request("/posts/2", { const res = await app.request("/entity/posts/2", {
method: "DELETE" method: "DELETE"
}); });
const { data } = (await res.json()) as RepositoryResponse<EntityData>; const { data } = (await res.json()) as RepositoryResponse<EntityData>;
expect(data).toEqual({ id: 2, ...fixtures.posts[1] }); expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
// verify // verify
const res2 = await app.request("/posts"); const res2 = await app.request("/entity/posts");
const data2 = (await res2.json()) as RepositoryResponse; const data2 = (await res2.json()) as RepositoryResponse;
expect(data2.meta.total).toBe(1); expect(data2.meta.total).toBe(1);
}); });
+17
View File
@@ -123,6 +123,23 @@ describe("data-query-impl", () => {
} }
} }
); );
// over http
{
const output = { with: { images: {} } };
decode({ with: "images" }, output);
decode({ with: '["images"]' }, output);
decode({ with: ["images"] }, output);
decode({ with: { images: {} } }, output);
}
{
const output = { with: { images: {}, comments: {} } };
decode({ with: "images,comments" }, output);
decode({ with: ["images", "comments"] }, output);
decode({ with: '["images", "comments"]' }, output);
decode({ with: { images: {}, comments: {} } }, output);
}
}); });
}); });
+3
View File
@@ -68,3 +68,6 @@ export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): En
const connection = conn ? conn : getDummyConnection().dummyConnection; const connection = conn ? conn : getDummyConnection().dummyConnection;
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices); return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
} }
export const assetsPath = `${import.meta.dir}/_assets`;
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
@@ -1,6 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { App, createApp } from "../../src"; import { App, createApp } from "../../src";
import type { AuthResponse } from "../../src/auth"; import type { AuthResponse } from "../../src/auth";
import { auth } from "../../src/auth/middlewares";
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils"; import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
import { disableConsoleLog, enableConsoleLog } from "../helper"; import { disableConsoleLog, enableConsoleLog } from "../helper";
@@ -98,7 +99,7 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
} }
return { return {
Authorization: `Bearer ${token}`, Authorization: token ? `Bearer ${token}` : "",
"Content-Type": "application/json", "Content-Type": "application/json",
...additional ...additional
}; };
@@ -210,4 +211,36 @@ describe("integration auth", () => {
expect(res.status).toBe(403); expect(res.status).toBe(403);
}); });
}); });
it("context is exclusive", async () => {
const app = createAuthApp();
await app.build();
const $fns = fns(app);
app.server.get("/get", auth(), async (c) => {
return c.json({
user: c.get("auth").user ?? null
});
});
app.server.get("/wait", auth(), async (c) => {
await new Promise((r) => setTimeout(r, 20));
return c.json({ ok: true });
});
const { data } = await $fns.login(configs.users.normal);
const me = await $fns.me(data.token);
expect(me.user.email).toBe(configs.users.normal.email);
app.server.request("/wait", {
headers: { Authorization: `Bearer ${data.token}` }
});
{
await new Promise((r) => setTimeout(r, 10));
const res = await app.server.request("/get");
const data = await res.json();
expect(data.user).toBe(null);
expect(await $fns.me()).toEqual({ user: null as any });
}
});
}); });
@@ -0,0 +1,24 @@
import { describe, expect, it } from "bun:test";
import { createApp } from "../../src";
import { Api } from "../../src/Api";
describe("integration config", () => {
it("should create an entity", async () => {
const app = createApp();
await app.build();
const api = new Api({
host: "http://localhost",
fetcher: app.server.request as typeof fetch
});
// create entity
await api.system.addConfig("data", "entities.posts", {
name: "posts",
config: { sort_field: "id", sort_dir: "asc" },
fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } },
type: "regular"
});
expect(app.em.entities.map((e) => e.name)).toContain("posts");
});
});
+85 -45
View File
@@ -1,56 +1,96 @@
import { describe, test } from "bun:test"; /// <reference types="@types/bun" />
import { Hono } from "hono";
import { Guard } from "../../src/auth";
import { EventManager } from "../../src/core/events";
import { EntityManager } from "../../src/data";
import { AppMedia } from "../../src/media/AppMedia";
import { MediaController } from "../../src/media/api/MediaController";
import { getDummyConnection } from "../helper";
const { dummyConnection, afterAllCleanup } = getDummyConnection(); import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createApp, registries } from "../../src";
import { StorageLocalAdapter } from "../../src/adapter/node";
import { mergeObject, randomString } from "../../src/core/utils";
import type { TAppMediaConfig } from "../../src/media/media-schema";
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
/** beforeAll(() => {
* R2 registries.media.register("local", StorageLocalAdapter);
* value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null, });
* Node writefile
* data: string | NodeJS.ArrayBufferView | Iterable<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | Stream,
*/
const ALL_TESTS = !!process.env.ALL_TESTS;
describe.skipIf(ALL_TESTS)("MediaController", () => {
test("..", async () => {
const ctx: any = {
em: new EntityManager([], dummyConnection, []),
guard: new Guard(),
emgr: new EventManager(),
server: new Hono()
};
const media = new AppMedia( const path = `${assetsPath}/image.png`;
// @ts-ignore
{ async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
enabled: true, const app = createApp({
adapter: { initialConfig: {
type: "s3", media: mergeObject(
config: { {
access_key: process.env.R2_ACCESS_KEY as string, enabled: true,
secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string, adapter: {
url: process.env.R2_URL as string type: "local",
config: {
path: assetsTmpPath
}
} }
} },
}, mediaOverride
ctx )
); }
await media.build(); });
const app = new MediaController(media).getController();
const file = Bun.file(`${import.meta.dir}/adapters/icon.png`); await app.build();
console.log("file", file); return app;
const form = new FormData(); }
form.append("file", file);
await app.request("/upload/test.png", { function makeName(ext: string) {
return randomString(10) + "." + ext;
}
beforeAll(disableConsoleLog);
afterAll(enableConsoleLog);
describe("MediaController", () => {
test("accepts direct", async () => {
const app = await makeApp();
const file = Bun.file(path);
const name = makeName("png");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST", method: "POST",
body: file body: file
}); });
const result = (await res.json()) as any;
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("accepts form data", async () => {
const app = await makeApp();
const file = Bun.file(path);
const name = makeName("png");
const form = new FormData();
form.append("file", file);
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: form
});
const result = (await res.json()) as any;
expect(result.name).toBe(name);
const destFile = Bun.file(assetsTmpPath + "/" + name);
expect(destFile.exists()).resolves.toBe(true);
await destFile.delete();
});
test("limits body", async () => {
const app = await makeApp({ storage: { body_max_size: 1 } });
const file = await Bun.file(path);
const name = makeName("png");
const res = await app.server.request("/api/media/upload/" + name, {
method: "POST",
body: file
});
expect(res.status).toBe(413);
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
}); });
}); });
@@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => {
test("puts an object", async () => { test("puts an object", async () => {
objects = (await adapter.listObjects()).length; objects = (await adapter.listObjects()).length;
expect(await adapter.putObject(filename, file)).toBeString(); expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
}); });
test("lists objects", async () => { test("lists objects", async () => {
+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");
});
}); });
+131 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { stripMark } from "../../src/core/utils"; import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
import { entity, text } from "../../src/data"; import { entity, text } from "../../src/data";
import { Module } from "../../src/modules/Module";
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager"; import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations"; import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
import { getDummyConnection } from "../helper"; import { getDummyConnection } from "../helper";
@@ -177,7 +178,7 @@ describe("ModuleManager", async () => {
await mm.build(); await mm.build();
const configs = stripMark(mm.configs()); const configs = stripMark(mm.configs());
expect(mm.configs().server.admin.color_scheme).toBe("light"); expect(mm.configs().server.admin.color_scheme).toBeUndefined();
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow(); expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
await mm.get("server").schema().patch("admin", { color_scheme: "dark" }); await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
await mm.save(); await mm.save();
@@ -252,4 +253,131 @@ describe("ModuleManager", async () => {
}); });
// @todo: add tests for migrations (check "backup" and new version) // @todo: add tests for migrations (check "backup" and new version)
describe("revert", async () => {
const failingModuleSchema = Type.Object({
value: Type.Optional(Type.Number())
});
class FailingModule extends Module<typeof failingModuleSchema> {
getSchema() {
return failingModuleSchema;
}
override async build() {
//console.log("building FailingModule", this.config);
if (this.config.value < 0) {
throw new Error("value must be positive");
}
this.setBuilt();
}
}
class TestModuleManager extends ModuleManager {
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
super(...args);
const [, options] = args;
// @ts-ignore
const initial = options?.initial?.failing ?? {};
this.modules["failing"] = new FailingModule(initial, this.ctx());
this.modules["failing"].setListener(async (c) => {
// @ts-ignore
await this.onModuleConfigUpdated("failing", c);
});
}
}
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
afterEach(enableConsoleLog);
test("it builds", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection);
expect(mm).toBeDefined();
await mm.build();
expect(mm.toJSON()).toBeDefined();
});
test("it accepts config", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
initial: {
// @ts-ignore
failing: { value: 2 }
}
});
await mm.build();
expect(mm.configs()["failing"].value).toBe(2);
});
test("it crashes on invalid", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
initial: {
// @ts-ignore
failing: { value: -1 }
}
});
expect(mm.build()).rejects.toThrow(/value must be positive/);
expect(mm.configs()["failing"].value).toBe(-1);
});
test("it correctly accepts valid", async () => {
const mockOnUpdated = mock(() => null);
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
onUpdated: async () => {
mockOnUpdated();
}
});
await mm.build();
// @ts-ignore
const f = mm.mutateConfigSafe("failing");
expect(f.set({ value: 2 })).resolves.toBeDefined();
expect(mockOnUpdated).toHaveBeenCalled();
});
test("it reverts on safe mutate", async () => {
const mockOnUpdated = mock(() => null);
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection, {
initial: {
// @ts-ignore
failing: { value: 1 }
},
onUpdated: async () => {
mockOnUpdated();
}
});
await mm.build();
expect(mm.configs()["failing"].value).toBe(1);
// now safe mutate
// @ts-ignore
expect(mm.mutateConfigSafe("failing").set({ value: -2 })).rejects.toThrow(
/value must be positive/
);
expect(mm.configs()["failing"].value).toBe(1);
expect(mockOnUpdated).toHaveBeenCalled();
});
test("it only accepts schema mutating methods", async () => {
const { dummyConnection } = getDummyConnection();
const mm = new TestModuleManager(dummyConnection);
await mm.build();
// @ts-ignore
const f = mm.mutateConfigSafe("failing");
// @ts-expect-error
expect(() => f.has("value")).toThrow();
// @ts-expect-error
expect(() => f.bypass()).toThrow();
// @ts-expect-error
expect(() => f.clone()).toThrow();
// @ts-expect-error
expect(() => f.get()).toThrow();
// @ts-expect-error
expect(() => f.default()).toThrow();
});
});
}); });
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, test } from "bun:test";
import { Draft2019 } from "json-schema-library";
import type { JSONSchema } from "json-schema-to-ts";
import * as utils from "../../src/ui/components/form/json-schema-form/utils";
import type { IsTypeType } from "../../src/ui/components/form/json-schema-form/utils";
describe("json form", () => {
test("coerse", () => {
const examples = [
["test", { type: "string" }, "test"],
["1", { type: "integer" }, 1],
["1", { type: "number" }, 1],
["true", { type: "boolean" }, true],
["false", { type: "boolean" }, false],
["1", { type: "boolean" }, true],
["0", { type: "boolean" }, false],
["on", { type: "boolean" }, true],
["off", { type: "boolean" }, false],
["null", { type: "null" }, null]
] satisfies [string, Exclude<JSONSchema, boolean>, any][];
for (const [input, schema, output] of examples) {
expect(utils.coerce(input, schema)).toBe(output);
}
});
test("isType", () => {
const examples = [
["string", "string", true],
["integer", "number", false],
["number", "number", true],
["boolean", "boolean", true],
["null", "null", true],
["object", "object", true],
["array", "array", true],
["object", "array", false],
[["string", "number"], "number", true],
["number", ["string", "number"], true]
] satisfies [IsTypeType, IsTypeType, boolean][];
for (const [type, schemaType, output] of examples) {
expect(utils.isType(type, schemaType)).toBe(output);
}
});
test("getParentPointer", () => {
const examples = [
["#/nested/property/0/name", "#/nested/property/0"],
["#/nested/property/0", "#/nested/property"],
["#/nested/property", "#/nested"],
["#/nested", "#"]
];
for (const [input, output] of examples) {
expect(utils.getParentPointer(input)).toBe(output);
}
});
test("isRequired", () => {
const examples = [
[
"#/description",
{ type: "object", properties: { description: { type: "string" } } },
false
],
[
"#/description",
{
type: "object",
required: ["description"],
properties: { description: { type: "string" } }
},
true
],
[
"#/nested/property",
{
type: "object",
properties: {
nested: {
type: "object",
properties: { property: { type: "string" } }
}
}
},
false
],
[
"#/nested/property",
{
type: "object",
properties: {
nested: {
type: "object",
required: ["property"],
properties: { property: { type: "string" } }
}
}
},
true
]
] satisfies [string, Exclude<JSONSchema, boolean>, boolean][];
for (const [pointer, schema, output] of examples) {
expect(utils.isRequired(new Draft2019(schema), pointer, schema)).toBe(output);
}
});
test("prefixPath", () => {
const examples = [
["normal", "", "normal"],
["", "prefix", "prefix"],
["tags", "0", "0.tags"],
["tags", 0, "0.tags"],
["nested.property", "prefix", "prefix.nested.property"],
["nested.property", "", "nested.property"]
] satisfies [string, any, string][];
for (const [path, prefix, output] of examples) {
expect(utils.prefixPath(path, prefix)).toBe(output);
}
});
test("suffixPath", () => {
const examples = [
["normal", "", "normal"],
["", "suffix", "suffix"],
["tags", "0", "tags.0"],
["tags", 0, "tags.0"],
["nested.property", "suffix", "nested.property.suffix"],
["nested.property", "", "nested.property"]
] satisfies [string, any, string][];
for (const [path, suffix, output] of examples) {
expect(utils.suffixPath(path, suffix)).toBe(output);
}
});
});
+142 -115
View File
@@ -49,111 +49,117 @@ if (types && !watch) {
/** /**
* Building backend and general API * Building backend and general API
*/ */
await tsup.build({ async function buildApi() {
minify, await tsup.build({
sourcemap, minify,
watch, sourcemap,
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"], watch,
outDir: "dist", entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
external: ["bun:test", "@libsql/client"], outDir: "dist",
metafile: true, external: ["bun:test", "@libsql/client", "bknd/client"],
platform: "browser", metafile: true,
format: ["esm"], platform: "browser",
splitting: false, format: ["esm"],
treeshake: true, splitting: false,
loader: { treeshake: true,
".svg": "dataurl" loader: {
}, ".svg": "dataurl"
onSuccess: async () => { },
delayTypes(); onSuccess: async () => {
} delayTypes();
}); }
});
}
/** /**
* Building UI for direct imports * Building UI for direct imports
*/ */
await tsup.build({ async function buildUi() {
minify, await tsup.build({
sourcemap, minify,
watch, sourcemap,
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"], watch,
outDir: "dist/ui", entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
external: [ outDir: "dist/ui",
"bun:test", external: [
"react", "bun:test",
"react-dom", "react",
"react/jsx-runtime", "react-dom",
"react/jsx-dev-runtime", "react/jsx-runtime",
"use-sync-external-store", "react/jsx-dev-runtime",
/codemirror/, "use-sync-external-store",
"@xyflow/react", /codemirror/,
"@mantine/core" "@xyflow/react",
], "@mantine/core"
metafile: true, ],
platform: "browser", metafile: true,
format: ["esm"], platform: "browser",
splitting: false, format: ["esm"],
bundle: true, splitting: false,
treeshake: true, bundle: true,
loader: { treeshake: true,
".svg": "dataurl" loader: {
}, ".svg": "dataurl"
esbuildOptions: (options) => { },
options.logLevel = "silent"; esbuildOptions: (options) => {
}, options.logLevel = "silent";
onSuccess: async () => { },
delayTypes(); onSuccess: async () => {
} delayTypes();
}); }
});
}
/** /**
* Building UI Elements * Building UI Elements
* - tailwind-merge is mocked, no exclude * - tailwind-merge is mocked, no exclude
* - ui/client is external, and after built replaced with "bknd/client" * - ui/client is external, and after built replaced with "bknd/client"
*/ */
await tsup.build({ async function buildUiElements() {
minify, await tsup.build({
sourcemap, minify,
watch, sourcemap,
entry: ["src/ui/elements/index.ts"], watch,
outDir: "dist/ui/elements", entry: ["src/ui/elements/index.ts"],
external: [ outDir: "dist/ui/elements",
"ui/client", external: [
"react", "ui/client",
"react-dom", "react",
"react/jsx-runtime", "react-dom",
"react/jsx-dev-runtime", "react/jsx-runtime",
"use-sync-external-store" "react/jsx-dev-runtime",
], "use-sync-external-store"
metafile: true, ],
platform: "browser", metafile: true,
format: ["esm"], platform: "browser",
splitting: false, format: ["esm"],
bundle: true, splitting: false,
treeshake: true, bundle: true,
loader: { treeshake: true,
".svg": "dataurl" loader: {
}, ".svg": "dataurl"
esbuildOptions: (options) => { },
options.alias = { esbuildOptions: (options) => {
// not important for elements, mock to reduce bundle options.alias = {
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts" // not important for elements, mock to reduce bundle
}; "tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts"
}, };
onSuccess: async () => { },
// manually replace ui/client with bknd/client onSuccess: async () => {
const path = "./dist/ui/elements/index.js"; // manually replace ui/client with bknd/client
const bundle = await Bun.file(path).text(); const path = "./dist/ui/elements/index.js";
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client")); const bundle = await Bun.file(path).text();
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
delayTypes(); delayTypes();
} }
}); });
}
/** /**
* Building adapters * Building adapters
*/ */
function baseConfig(adapter: string): tsup.Options { function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
return { return {
minify, minify,
sourcemap, sourcemap,
@@ -162,40 +168,61 @@ function baseConfig(adapter: string): tsup.Options {
format: ["esm"], format: ["esm"],
platform: "neutral", platform: "neutral",
outDir: `dist/adapter/${adapter}`, outDir: `dist/adapter/${adapter}`,
metafile: true,
splitting: false,
onSuccess: async () => {
delayTypes();
},
...overrides,
define: { define: {
__isDev: "0" __isDev: "0",
...overrides.define
}, },
external: [ external: [
/^cloudflare*/, /^cloudflare*/,
/^@?(hono|libsql).*?/, /^@?(hono|libsql).*?/,
/^(bknd|react|next|node).*?/, /^(bknd|react|next|node).*?/,
/.*\.(html)$/ /.*\.(html)$/,
], ...(Array.isArray(overrides.external) ? overrides.external : [])
metafile: true, ]
splitting: false,
treeshake: true,
onSuccess: async () => {
delayTypes();
}
}; };
} }
await tsup.build(baseConfig("remix")); async function buildAdapters() {
await tsup.build(baseConfig("bun")); // base adapter handles
await tsup.build(baseConfig("astro")); await tsup.build({
await tsup.build(baseConfig("cloudflare")); ...baseConfig(""),
entry: ["src/adapter/index.ts"],
outDir: "dist/adapter"
});
await tsup.build({ // specific adatpers
...baseConfig("vite"), await tsup.build(baseConfig("remix"));
platform: "node" await tsup.build(baseConfig("bun"));
}); await tsup.build(baseConfig("astro"));
await tsup.build(
baseConfig("cloudflare", {
external: [/^kysely/]
})
);
await tsup.build({ await tsup.build({
...baseConfig("nextjs"), ...baseConfig("vite"),
platform: "node" platform: "node"
}); });
await tsup.build({ await tsup.build({
...baseConfig("node"), ...baseConfig("nextjs"),
platform: "node" platform: "node"
}); });
await tsup.build({
...baseConfig("node"),
platform: "node"
});
}
await buildApi();
await buildUi();
await buildUiElements();
await buildAdapters();
+25 -14
View File
@@ -3,7 +3,7 @@
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
"bin": "./dist/cli/index.js", "bin": "./dist/cli/index.js",
"version": "0.6.2", "version": "0.8.1",
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.", "description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
"homepage": "https://bknd.io", "homepage": "https://bknd.io",
"repository": { "repository": {
@@ -22,9 +22,9 @@
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify", "build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
"build:static": "vite build", "build:static": "vite build",
"watch": "bun run build.ts --types --watch", "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", "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", "updater": "bun x npm-check-updates -ui",
"cli": "LOCAL=1 bun src/cli/index.ts", "cli": "LOCAL=1 bun src/cli/index.ts",
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./", "prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
@@ -33,36 +33,41 @@
"license": "FSL-1.1-MIT", "license": "FSL-1.1-MIT",
"dependencies": { "dependencies": {
"@cfworker/json-schema": "^2.0.1", "@cfworker/json-schema": "^2.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@hello-pangea/dnd": "^17.0.0",
"@libsql/client": "^0.14.0", "@libsql/client": "^0.14.0",
"@mantine/core": "^7.13.4",
"@sinclair/typebox": "^0.32.34", "@sinclair/typebox": "^0.32.34",
"@tanstack/react-form": "0.19.2", "@tanstack/react-form": "0.19.2",
"@uiw/react-codemirror": "^4.23.6",
"@xyflow/react": "^12.3.2",
"aws4fetch": "^1.0.18", "aws4fetch": "^1.0.18",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"fast-xml-parser": "^4.4.0", "fast-xml-parser": "^4.4.0",
"hono": "^4.6.12", "hono": "^4.6.12",
"json-schema-form-react": "^0.0.2",
"json-schema-library": "^10.0.0-rc7",
"json-schema-to-ts": "^3.1.1",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"liquidjs": "^10.15.0", "liquidjs": "^10.15.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"oauth4webapi": "^2.11.1", "oauth4webapi": "^2.11.1",
"swr": "^2.2.5", "object-path-immutable": "^4.1.2",
"json-schema-form-react": "^0.0.2", "picocolors": "^1.1.1",
"@uiw/react-codemirror": "^4.23.6", "radix-ui": "^1.1.2",
"@codemirror/lang-html": "^6.4.9", "swr": "^2.2.5"
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-liquid": "^6.2.1",
"@xyflow/react": "^12.3.2",
"@mantine/core": "^7.13.4",
"@hello-pangea/dnd": "^17.0.0"
}, },
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.613.0", "@aws-sdk/client-s3": "^3.613.0",
"@bluwy/giget-core": "^0.1.2",
"@dagrejs/dagre": "^1.1.4", "@dagrejs/dagre": "^1.1.4",
"@hono/typebox-validator": "^0.2.6", "@hono/typebox-validator": "^0.2.6",
"@hono/vite-dev-server": "^0.17.0", "@hono/vite-dev-server": "^0.17.0",
"@hono/zod-validator": "^0.4.1", "@hono/zod-validator": "^0.4.1",
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@libsql/kysely-libsql": "^0.4.1", "@libsql/kysely-libsql": "^0.4.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@rjsf/core": "^5.22.2", "@rjsf/core": "^5.22.2",
"@tabler/icons-react": "3.18.0", "@tabler/icons-react": "3.18.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
@@ -71,8 +76,10 @@
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7",
"esbuild-postcss": "^0.0.4", "esbuild-postcss": "^0.0.4",
"jotai": "^2.10.1", "jotai": "^2.10.1",
"kysely-d1": "^0.3.0",
"open": "^10.1.0", "open": "^10.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"postcss": "^8.4.47", "postcss": "^8.4.47",
@@ -148,6 +155,10 @@
"import": "./dist/adapter/cloudflare/index.js", "import": "./dist/adapter/cloudflare/index.js",
"require": "./dist/adapter/cloudflare/index.cjs" "require": "./dist/adapter/cloudflare/index.cjs"
}, },
"./adapter": {
"types": "./dist/types/adapter/index.d.ts",
"import": "./dist/adapter/index.js"
},
"./adapter/vite": { "./adapter/vite": {
"types": "./dist/types/adapter/vite/index.d.ts", "types": "./dist/types/adapter/vite/index.d.ts",
"import": "./dist/adapter/vite/index.js", "import": "./dist/adapter/vite/index.js",
@@ -210,4 +221,4 @@
"bun", "bun",
"node" "node"
] ]
} }
+73 -28
View File
@@ -17,13 +17,22 @@ declare global {
} }
export type ApiOptions = { export type ApiOptions = {
host: string; host?: string;
user?: TApiUser;
token?: string;
headers?: Headers; headers?: Headers;
key?: string; key?: string;
localStorage?: boolean; localStorage?: boolean;
}; fetcher?: typeof fetch;
verbose?: boolean;
verified?: boolean;
} & (
| {
token?: string;
user?: TApiUser;
}
| {
request: Request;
}
);
export type AuthState = { export type AuthState = {
token?: string; token?: string;
@@ -42,14 +51,26 @@ export class Api {
public auth!: AuthApi; public auth!: AuthApi;
public media!: MediaApi; public media!: MediaApi;
constructor(private readonly options: ApiOptions) { constructor(private options: ApiOptions = {}) {
if (options.user) { // only mark verified if forced
this.user = options.user; this.verified = options.verified === true;
this.token_transport = "none";
this.verified = true; // prefer request if given
} else if (options.token) { if ("request" in options) {
this.options.host = options.host ?? new URL(options.request.url).origin;
this.options.headers = options.headers ?? options.request.headers;
this.extractToken();
// then check for a token
} else if ("token" in options) {
this.token_transport = "header"; this.token_transport = "header";
this.updateToken(options.token); this.updateToken(options.token);
// then check for an user object
} else if ("user" in options) {
this.token_transport = "none";
this.user = options.user;
this.verified = options.verified !== false;
} else { } else {
this.extractToken(); this.extractToken();
} }
@@ -58,7 +79,7 @@ export class Api {
} }
get baseUrl() { get baseUrl() {
return this.options.host; return this.options.host ?? "http://localhost";
} }
get tokenKey() { get tokenKey() {
@@ -66,13 +87,15 @@ export class Api {
} }
private extractToken() { private extractToken() {
// if token has to be extracted, it's never verified
this.verified = false;
if (this.options.headers) { if (this.options.headers) {
// try cookies // try cookies
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth"); const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
if (cookieToken) { if (cookieToken) {
this.updateToken(cookieToken);
this.token_transport = "cookie"; this.token_transport = "cookie";
this.verified = true; this.updateToken(cookieToken);
return; return;
} }
@@ -96,6 +119,8 @@ export class Api {
updateToken(token?: string, rebuild?: boolean) { updateToken(token?: string, rebuild?: boolean) {
this.token = token; this.token = token;
this.verified = false;
if (token) { if (token) {
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any; this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
} else { } else {
@@ -115,11 +140,15 @@ export class Api {
if (rebuild) this.buildApis(); if (rebuild) this.buildApis();
} }
markAuthVerified(verfied: boolean) { private markAuthVerified(verfied: boolean) {
this.verified = verfied; this.verified = verfied;
return this; return this;
} }
isAuthVerified(): boolean {
return this.verified;
}
getAuthState(): AuthState { getAuthState(): AuthState {
return { return {
token: this.token, token: this.token,
@@ -128,6 +157,11 @@ export class Api {
}; };
} }
isAuthenticated(): boolean {
const { token, user } = this.getAuthState();
return !!token && !!user;
}
async getVerifiedAuthState(): Promise<AuthState> { async getVerifiedAuthState(): Promise<AuthState> {
await this.verifyAuth(); await this.verifyAuth();
return this.getAuthState(); return this.getAuthState();
@@ -140,11 +174,13 @@ export class Api {
} }
try { try {
const res = await this.auth.me(); const { ok, data } = await this.auth.me();
if (!res.ok || !res.body.user) { const user = data?.user;
if (!ok || !user) {
throw new Error(); throw new Error();
} }
this.user = user;
this.markAuthVerified(true); this.markAuthVerified(true);
} catch (e) { } catch (e) {
this.markAuthVerified(false); this.markAuthVerified(false);
@@ -156,21 +192,30 @@ export class Api {
return this.user || null; return this.user || null;
} }
private buildApis() { getParams() {
const baseParams = { return Object.freeze({
host: this.options.host, host: this.baseUrl,
token: this.token, token: this.token,
headers: this.options.headers, headers: this.options.headers,
token_transport: this.token_transport token_transport: this.token_transport,
}; verbose: this.options.verbose
this.system = new SystemApi(baseParams);
this.data = new DataApi(baseParams);
this.auth = new AuthApi({
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
}); });
this.media = new MediaApi(baseParams); }
private buildApis() {
const baseParams = this.getParams();
const fetcher = this.options.fetcher;
this.system = new SystemApi(baseParams, fetcher);
this.data = new DataApi(baseParams, fetcher);
this.auth = new AuthApi(
{
...baseParams,
onTokenUpdate: (token) => this.updateToken(token, true)
},
fetcher
);
this.media = new MediaApi(baseParams, fetcher);
} }
} }
+52 -30
View File
@@ -1,8 +1,12 @@
import { Api, type ApiOptions } from "Api";
import type { CreateUserPayload } from "auth/AppAuth"; import type { CreateUserPayload } from "auth/AppAuth";
import { $console } from "core";
import { Event } from "core/events"; import { Event } from "core/events";
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data"; import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
import type { Hono } from "hono";
import { import {
type InitialModuleConfigs, type InitialModuleConfigs,
type ModuleBuildContext,
ModuleManager, ModuleManager,
type ModuleManagerOptions, type ModuleManagerOptions,
type Modules type Modules
@@ -25,16 +29,22 @@ export class AppFirstBoot extends AppEvent {
} }
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const; export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
export type AppOptions = {
plugins?: AppPlugin[];
seed?: (ctx: ModuleBuildContext) => Promise<void>;
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
};
export type CreateAppConfig = { export type CreateAppConfig = {
connection?: connection?:
| Connection | Connection
| { | {
// @deprecated
type: "libsql"; type: "libsql";
config: LibSqlCredentials; config: LibSqlCredentials;
}; }
| LibSqlCredentials;
initialConfig?: InitialModuleConfigs; initialConfig?: InitialModuleConfigs;
plugins?: AppPlugin[]; options?: AppOptions;
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
}; };
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
@@ -44,30 +54,34 @@ export class App {
static readonly Events = AppEvents; static readonly Events = AppEvents;
adminController?: AdminController; adminController?: AdminController;
private trigger_first_boot = false; private trigger_first_boot = false;
private plugins: AppPlugin[];
constructor( constructor(
private connection: Connection, private connection: Connection,
_initialConfig?: InitialModuleConfigs, _initialConfig?: InitialModuleConfigs,
private plugins: AppPlugin[] = [], private options?: AppOptions
moduleManagerOptions?: ModuleManagerOptions
) { ) {
this.plugins = options?.plugins ?? [];
this.modules = new ModuleManager(connection, { this.modules = new ModuleManager(connection, {
...moduleManagerOptions, ...(options?.manager ?? {}),
initial: _initialConfig, initial: _initialConfig,
seed: options?.seed,
onUpdated: async (key, config) => { onUpdated: async (key, config) => {
// if the EventManager was disabled, we assume we shouldn't // if the EventManager was disabled, we assume we shouldn't
// respond to events, such as "onUpdated". // respond to events, such as "onUpdated".
// this is important if multiple changes are done, and then build() is called manually
if (!this.emgr.enabled) { if (!this.emgr.enabled) {
console.warn("[APP] config updated, but event manager is disabled, skip."); $console.warn("App config updated, but event manager is disabled, skip.");
return; return;
} }
console.log("[APP] config updated", key); $console.log("App config updated", key);
await this.build({ sync: true, save: true }); // @todo: potentially double syncing
await this.build({ sync: true });
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this })); await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
}, },
onFirstBoot: async () => { onFirstBoot: async () => {
console.log("[APP] first boot"); $console.log("App first boot");
this.trigger_first_boot = true; this.trigger_first_boot = true;
}, },
onServerInit: async (server) => { onServerInit: async (server) => {
@@ -84,16 +98,10 @@ export class App {
return this.modules.ctx().emgr; return this.modules.ctx().emgr;
} }
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) { async build(options?: { sync?: boolean }) {
if (options?.sync) this.modules.ctx().flags.sync_required = true;
await this.modules.build(); await this.modules.build();
if (options?.sync) {
const syncResult = await this.module.data.em
.schema()
.sync({ force: true, drop: options.drop });
//console.log("syncing", syncResult);
}
const { guard, server } = this.modules.ctx(); const { guard, server } = this.modules.ctx();
// load system controller // load system controller
@@ -107,12 +115,6 @@ export class App {
await this.emgr.emit(new AppBuiltEvent({ app: this })); await this.emgr.emit(new AppBuiltEvent({ app: this }));
server.all("/api/*", async (c) => c.notFound());
if (options?.save) {
await this.modules.save();
}
// first boot is set from ModuleManager when there wasn't a config table // first boot is set from ModuleManager when there wasn't a config table
if (this.trigger_first_boot) { if (this.trigger_first_boot) {
this.trigger_first_boot = false; this.trigger_first_boot = false;
@@ -121,7 +123,7 @@ export class App {
} }
mutateConfig<Module extends keyof Modules>(module: Module) { mutateConfig<Module extends keyof Modules>(module: Module) {
return this.modules.get(module).schema(); return this.modules.mutateConfigSafe(module);
} }
get server() { get server() {
@@ -132,7 +134,7 @@ export class App {
return this.modules.ctx().em; return this.modules.ctx().em;
} }
get fetch(): any { get fetch(): Hono["fetch"] {
return this.server.fetch; return this.server.fetch;
} }
@@ -155,6 +157,10 @@ export class App {
return this.modules.version(); return this.modules.version();
} }
isBuilt(): boolean {
return this.modules.isBuilt();
}
registerAdminController(config?: AdminControllerOptions) { registerAdminController(config?: AdminControllerOptions) {
// register admin // register admin
this.adminController = new AdminController(this, config); this.adminController = new AdminController(this, config);
@@ -173,6 +179,15 @@ export class App {
async createUser(p: CreateUserPayload) { async createUser(p: CreateUserPayload) {
return this.module.auth.createUser(p); return this.module.auth.createUser(p);
} }
getApi(options: Request | ApiOptions = {}) {
const fetcher = this.server.request as typeof fetch;
if (options instanceof Request) {
return new Api({ request: options, headers: options.headers, fetcher });
}
return new Api({ host: "http://localhost", ...options, fetcher });
}
} }
export function createApp(config: CreateAppConfig = {}) { export function createApp(config: CreateAppConfig = {}) {
@@ -182,18 +197,25 @@ export function createApp(config: CreateAppConfig = {}) {
if (Connection.isConnection(config.connection)) { if (Connection.isConnection(config.connection)) {
connection = config.connection; connection = config.connection;
} else if (typeof config.connection === "object") { } else if (typeof config.connection === "object") {
connection = new LibsqlConnection(config.connection.config); if ("type" in config.connection) {
$console.warn(
"Using deprecated connection type 'libsql', use the 'config' object directly."
);
connection = new LibsqlConnection(config.connection.config);
} else {
connection = new LibsqlConnection(config.connection);
}
} else { } else {
connection = new LibsqlConnection({ url: ":memory:" }); connection = new LibsqlConnection({ url: ":memory:" });
console.warn("[!] No connection provided, using in-memory database"); $console.warn("No connection provided, using in-memory database");
} }
} catch (e) { } catch (e) {
console.error("Could not create connection", e); $console.error("Could not create connection", e);
} }
if (!connection) { if (!connection) {
throw new Error("Invalid connection"); throw new Error("Invalid connection");
} }
return new App(connection, config.initialConfig, config.plugins, config.options); return new App(connection, config.initialConfig, config.options);
} }
+11 -8
View File
@@ -1,7 +1,8 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter"; import type { App } from "bknd";
import { Api, type ApiOptions, type App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api, type ApiOptions } from "bknd/client";
export type AstroBkndConfig = FrameworkBkndConfig; export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
type TAstro = { type TAstro = {
request: Request; request: Request;
@@ -13,18 +14,20 @@ export type Options = {
host?: string; host?: string;
}; };
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) { export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
return new Api({ const api = new Api({
host: new URL(Astro.request.url).origin, host: new URL(Astro.request.url).origin,
headers: options.mode === "dynamic" ? Astro.request.headers : undefined headers: options.mode === "dynamic" ? Astro.request.headers : undefined
}); });
await api.verifyAuth();
return api;
} }
let app: App; let app: App;
export function serve(config: AstroBkndConfig = {}) { export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
return async (args: TAstro) => { return async (args: Context) => {
if (!app) { if (!app) {
app = await createFrameworkApp(config); app = await createFrameworkApp(config, args);
} }
return app.fetch(args.request); return app.fetch(args.request);
}; };
+4 -5
View File
@@ -2,10 +2,11 @@
import path from "node:path"; import path from "node:path";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { config } from "bknd/core";
import type { ServeOptions } from "bun"; import type { ServeOptions } from "bun";
import { config } from "core";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
let app: App; let app: App;
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static"); const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
if (!app) { if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({ app = await createRuntimeApp({
...config, ...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root }) serveStatic: serveStatic({ root })
}); });
} }
@@ -29,7 +30,6 @@ export function serve({
distPath, distPath,
connection, connection,
initialConfig, initialConfig,
plugins,
options, options,
port = config.server.default_port, port = config.server.default_port,
onBuilt, onBuilt,
@@ -43,7 +43,6 @@ export function serve({
const app = await createApp({ const app = await createApp({
connection, connection,
initialConfig, initialConfig,
plugins,
options, options,
onBuilt, onBuilt,
buildConfig, buildConfig,
@@ -0,0 +1,63 @@
/// <reference types="@cloudflare/workers-types" />
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
import type { QB } from "data/connection/Connection";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { D1Dialect } from "kysely-d1";
export type D1ConnectionConfig = {
binding: D1Database;
};
class CustomD1Dialect extends D1Dialect {
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
return new SqliteIntrospector(db, {
excludeTables: ["_cf_KV"]
});
}
}
export class D1Connection extends SqliteConnection {
constructor(private config: D1ConnectionConfig) {
const plugins = [new ParseJSONResultsPlugin()];
const kysely = new Kysely({
dialect: new CustomD1Dialect({ database: config.binding }),
plugins
});
super(kysely, {}, plugins);
}
override supportsBatching(): boolean {
return true;
}
override supportsIndices(): boolean {
return true;
}
protected override async batch<Queries extends QB[]>(
queries: [...Queries]
): Promise<{
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
}> {
const db = this.config.binding;
const res = await db.batch(
queries.map((q) => {
const { sql, parameters } = q.compile();
return db.prepare(sql).bind(...parameters);
})
);
// let it run through plugins
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
const data: any = [];
for (const r of res) {
const rows = await kyselyPlugins.transformResultRows(r.results);
data.push(rows);
}
return data;
}
}
@@ -1,6 +1,47 @@
import { isDebug } from "core"; import { registries } from "bknd";
import type { FileBody, StorageAdapter } from "../Storage"; import { isDebug } from "bknd/core";
import { guessMimeType } from "../mime-types"; import { StringEnum, Type } from "bknd/utils";
import type { FileBody, StorageAdapter } from "media/storage/Storage";
import { guess } from "media/storage/mime-types-tiny";
import { getBindings } from "./bindings";
export function makeSchema(bindings: string[] = []) {
return Type.Object(
{
binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String())
},
{ title: "R2", description: "Cloudflare R2 storage" }
);
}
export function registerMedia(env: Record<string, any>) {
const r2_bindings = getBindings(env, "R2Bucket");
registries.media.register(
"r2",
class extends StorageR2Adapter {
constructor(private config: any) {
const binding = r2_bindings.find((b) => b.key === config.binding);
if (!binding) {
throw new Error(`No R2Bucket found with key ${config.binding}`);
}
super(binding?.value);
}
override getSchema() {
return makeSchema(r2_bindings.map((b) => b.key));
}
override toJSON() {
return {
...super.toJSON(),
config: this.config
};
}
}
);
}
/** /**
* Adapter for R2 storage * Adapter for R2 storage
@@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter {
} }
getSchema() { getSchema() {
return undefined; return makeSchema();
} }
async putObject(key: string, body: FileBody) { async putObject(key: string, body: FileBody) {
@@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter {
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
let object: R2ObjectBody | null; let object: R2ObjectBody | null;
const responseHeaders = new Headers({ const responseHeaders = new Headers({
"Accept-Ranges": "bytes" "Accept-Ranges": "bytes",
"Content-Type": guess(key)
}); });
//console.log("getObject:headers", headersToObject(headers)); //console.log("getObject:headers", headersToObject(headers));
@@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter {
if (!metadata || Object.keys(metadata).length === 0) { if (!metadata || Object.keys(metadata).length === 0) {
// guessing is especially required for dev environment (miniflare) // guessing is especially required for dev environment (miniflare)
metadata = { metadata = {
contentType: guessMimeType(object.key) contentType: guess(object.key)
}; };
} }
//console.log("writeHttpMetadata", object.httpMetadata, metadata);
for (const [key, value] of Object.entries(metadata)) { for (const [key, value] of Object.entries(metadata)) {
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase(); const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
@@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter {
} }
return { return {
type: String(head.httpMetadata?.contentType ?? "application/octet-stream"), type: String(head.httpMetadata?.contentType ?? guess(key)),
size: head.size size: head.size
}; };
} }
+32
View File
@@ -0,0 +1,32 @@
export type BindingTypeMap = {
D1Database: D1Database;
KVNamespace: KVNamespace;
DurableObjectNamespace: DurableObjectNamespace;
R2Bucket: R2Bucket;
};
export type GetBindingType = keyof BindingTypeMap;
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
const bindings: BindingMap<T>[] = [];
for (const key in env) {
try {
if (env[key] && (env[key] as any).constructor.name === type) {
bindings.push({
key,
value: env[key] as BindingTypeMap[T]
});
}
} catch (e) {}
}
return bindings;
}
export function getBinding<T extends GetBindingType>(env: any, type: T): BindingMap<T> {
const bindings = getBindings(env, type);
if (bindings.length === 0) {
throw new Error(`No ${type} found in bindings`);
}
return bindings[0] as BindingMap<T>;
}
@@ -1,18 +1,23 @@
import type { CreateAppConfig } from "bknd"; /// <reference types="@cloudflare/workers-types" />
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers"; import { serveStatic } from "hono/cloudflare-workers";
import type { FrameworkBkndConfig } from "../index"; import { D1Connection } from "./D1Connection";
import { registerMedia } from "./StorageR2Adapter";
import { getBinding } from "./bindings";
import { getCached } from "./modes/cached"; import { getCached } from "./modes/cached";
import { getDurable } from "./modes/durable"; import { getDurable } from "./modes/durable";
import { getFresh, getWarm } from "./modes/fresh"; import { getFresh, getWarm } from "./modes/fresh";
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & { export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
mode?: "warm" | "fresh" | "cache" | "durable"; mode?: "warm" | "fresh" | "cache" | "durable";
bindings?: (env: Env) => { bindings?: (args: Context<Env>) => {
kv?: KVNamespace; kv?: KVNamespace;
dobj?: DurableObjectNamespace; dobj?: DurableObjectNamespace;
db?: D1Database;
}; };
static?: "kv" | "assets";
key?: string; key?: string;
keepAliveSeconds?: number; keepAliveSeconds?: number;
forceHttps?: boolean; forceHttps?: boolean;
@@ -21,28 +26,65 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
html?: string; html?: string;
}; };
export type Context = { export type Context<Env = any> = {
request: Request; request: Request;
env: any; env: Env;
ctx: ExecutionContext; ctx: ExecutionContext;
}; };
export function serve(config: CloudflareBkndConfig) { let media_registered: boolean = false;
return { export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
async fetch(request: Request, env: any, ctx: ExecutionContext) { if (!media_registered) {
const url = new URL(request.url); registerMedia(context.env as any);
const manifest = config.manifest; media_registered = true;
}
if (manifest) { const appConfig = makeConfig(config, context);
const bindings = config.bindings?.(context);
if (!appConfig.connection) {
let db: D1Database | undefined;
if (bindings?.db) {
console.log("Using database from bindings");
db = bindings.db;
} else if (Object.keys(context.env ?? {}).length > 0) {
const binding = getBinding(context.env, "D1Database");
if (binding) {
console.log(`Using database from env "${binding.key}"`);
db = binding.value;
}
}
if (db) {
appConfig.connection = new D1Connection({ binding: db });
} else {
throw new Error("No database connection given");
}
}
return appConfig;
}
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
return {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const url = new URL(request.url);
if (config.manifest && config.static === "assets") {
console.warn("manifest is not useful with static 'assets'");
} else if (!config.manifest && config.static === "kv") {
throw new Error("manifest is required with static 'kv'");
}
if (config.manifest && config.static !== "assets") {
const pathname = url.pathname.slice(1); const pathname = url.pathname.slice(1);
const assetManifest = JSON.parse(manifest); const assetManifest = JSON.parse(config.manifest);
if (pathname && pathname in assetManifest) { if (pathname && pathname in assetManifest) {
const hono = new Hono(); const hono = new Hono();
hono.all("*", async (c, next) => { hono.all("*", async (c, next) => {
const res = await serveStatic({ const res = await serveStatic({
path: `./${pathname}`, path: `./${pathname}`,
manifest manifest: config.manifest!
})(c as any, next); })(c as any, next);
if (res instanceof Response) { if (res instanceof Response) {
const ttl = 60 * 60 * 24 * 365; const ttl = 60 * 60 * 24 * 365;
@@ -57,8 +99,6 @@ export function serve(config: CloudflareBkndConfig) {
} }
} }
config.setAdminHtml = config.setAdminHtml && !!config.manifest;
const context = { request, env, ctx } as Context; const context = { request, env, ctx } as Context;
const mode = config.mode ?? "warm"; const mode = config.mode ?? "warm";
+14
View File
@@ -1,4 +1,18 @@
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
export * from "./cloudflare-workers.adapter"; export * from "./cloudflare-workers.adapter";
export { makeApp, getFresh, getWarm } from "./modes/fresh"; export { makeApp, getFresh, getWarm } from "./modes/fresh";
export { getCached } from "./modes/cached"; export { getCached } from "./modes/cached";
export { DurableBkndApp, getDurable } from "./modes/durable"; export { DurableBkndApp, getDurable } from "./modes/durable";
export { D1Connection, type D1ConnectionConfig };
export {
getBinding,
getBindings,
type BindingTypeMap,
type GetBindingType,
type BindingMap
} from "./bindings";
export function d1(config: D1ConnectionConfig) {
return new D1Connection(config);
}
+5 -5
View File
@@ -1,8 +1,8 @@
import { createRuntimeApp } from "adapter";
import { App } from "bknd"; import { App } from "bknd";
import type { CloudflareBkndConfig, Context } from "../index"; import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) { export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
const { kv } = config.bindings?.(env)!; const { kv } = config.bindings?.(env)!;
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings"); if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
const key = config.key ?? "app"; const key = config.key ?? "app";
@@ -16,7 +16,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
const app = await createRuntimeApp( const app = await createRuntimeApp(
{ {
...config, ...makeCfConfig(config, { env, ctx, ...args }),
initialConfig, initialConfig,
onBuilt: async (app) => { onBuilt: async (app) => {
app.module.server.client.get("/__bknd/cache", async (c) => { app.module.server.client.get("/__bknd/cache", async (c) => {
@@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
}, },
adminOptions: { html: config.html } adminOptions: { html: config.html }
}, },
env { env, ctx, ...args }
); );
if (!cachedConfig) { if (!cachedConfig) {
+3 -3
View File
@@ -1,7 +1,7 @@
import { DurableObject } from "cloudflare:workers"; import { DurableObject } from "cloudflare:workers";
import { createRuntimeApp } from "adapter";
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
import type { App, CreateAppConfig } from "bknd"; import type { App, CreateAppConfig } from "bknd";
import { createRuntimeApp, makeConfig } from "bknd/adapter";
import type { CloudflareBkndConfig, Context } from "../index";
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) { export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const { dobj } = config.bindings?.(ctx.env)!; const { dobj } = config.bindings?.(ctx.env)!;
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
const id = dobj.idFromName(key); const id = dobj.idFromName(key);
const stub = dobj.get(id) as unknown as DurableBkndApp; const stub = dobj.get(id) as unknown as DurableBkndApp;
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app; const create_config = makeConfig(config, ctx);
const res = await stub.fire(ctx.request, { const res = await stub.fire(ctx.request, {
config: create_config, config: create_config,
+5 -5
View File
@@ -1,14 +1,14 @@
import { createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import type { CloudflareBkndConfig, Context } from "../index"; import { createRuntimeApp } from "bknd/adapter";
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) { export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...makeCfConfig(config, ctx),
adminOptions: config.html ? { html: config.html } : undefined adminOptions: config.html ? { html: config.html } : undefined
}, },
env ctx
); );
} }
+13 -49
View File
@@ -1,59 +1,29 @@
import type { IncomingMessage } from "node:http"; import { App, type CreateAppConfig } from "bknd";
import { App, type CreateAppConfig, registries } from "bknd"; import { config as $config } from "bknd/core";
import { config as $config } from "core";
import type { MiddlewareHandler } from "hono"; import type { MiddlewareHandler } from "hono";
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
import type { AdminControllerOptions } from "modules/server/AdminController"; import type { AdminControllerOptions } from "modules/server/AdminController";
export type BkndConfig<Env = any> = CreateAppConfig & { export type BkndConfig<Args = any> = CreateAppConfig & {
app?: CreateAppConfig | ((env: Env) => CreateAppConfig); app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
onBuilt?: (app: App) => Promise<void>; onBuilt?: (app: App) => Promise<void>;
beforeBuild?: (app: App) => Promise<void>; beforeBuild?: (app: App) => Promise<void>;
buildConfig?: Parameters<App["build"]>[0]; buildConfig?: Parameters<App["build"]>[0];
}; };
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>; export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & { export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
distPath?: string; distPath?: string;
}; };
export function nodeRequestToRequest(req: IncomingMessage): Request { export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
let protocol = "http";
try {
protocol = req.headers["x-forwarded-proto"] as string;
} catch (e) {}
const host = req.headers.host;
const url = `${protocol}://${host}${req.url}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
headers.append(key, value.join(", "));
} else if (value) {
headers.append(key, value);
}
}
const method = req.method || "GET";
return new Request(url, {
method,
headers
});
}
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
let additionalConfig: CreateAppConfig = {}; let additionalConfig: CreateAppConfig = {};
if ("app" in config && config.app) { if ("app" in config && config.app) {
if (typeof config.app === "function") { if (typeof config.app === "function") {
if (!env) { if (!args) {
throw new Error("env is required when config.app is a function"); throw new Error("args is required when config.app is a function");
} }
additionalConfig = config.app(env); additionalConfig = config.app(args);
} else { } else {
additionalConfig = config.app; additionalConfig = config.app;
} }
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
return { ...config, ...additionalConfig }; return { ...config, ...additionalConfig };
} }
export async function createFrameworkApp<Env = any>( export async function createFrameworkApp<Args = any>(
config: FrameworkBkndConfig, config: FrameworkBkndConfig,
env?: Env args?: Args
): Promise<App> { ): Promise<App> {
const app = App.create(makeConfig(config, env)); const app = App.create(makeConfig(config, args));
if (config.onBuilt) { if (config.onBuilt) {
app.emgr.onEvent( app.emgr.onEvent(
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
export async function createRuntimeApp<Env = any>( export async function createRuntimeApp<Env = any>(
{ {
serveStatic, serveStatic,
registerLocalMedia,
adminOptions, adminOptions,
...config ...config
}: RuntimeBkndConfig & { }: RuntimeBkndConfig & {
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler]; serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
registerLocalMedia?: boolean;
adminOptions?: AdminControllerOptions | false; adminOptions?: AdminControllerOptions | false;
}, },
env?: Env env?: Env
): Promise<App> { ): Promise<App> {
if (registerLocalMedia) {
registerLocalMediaAdapter();
}
const app = App.create(makeConfig(config, env)); const app = App.create(makeConfig(config, env));
app.emgr.onEvent( app.emgr.onEvent(
+8 -4
View File
@@ -1,6 +1,8 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Api, type App } from "bknd"; import { nodeRequestToRequest } from "adapter/utils";
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
import { Api } from "bknd/client";
export type NextjsBkndConfig = FrameworkBkndConfig & { export type NextjsBkndConfig = FrameworkBkndConfig & {
cleanSearch?: string[]; cleanSearch?: string[];
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
} }
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) { export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
return (ctx: GetServerSidePropsContext & { api: Api }) => { return async (ctx: GetServerSidePropsContext & { api: Api }) => {
return handler({ ...ctx, api: createApi(ctx) }); const api = createApi(ctx);
await api.verifyAuth();
return handler({ ...ctx, api });
}; };
} }
+11 -5
View File
@@ -1,6 +1,12 @@
export * from "./node.adapter"; import { registries } from "bknd";
export { import {
StorageLocalAdapter, type LocalAdapterConfig,
type LocalAdapterConfig StorageLocalAdapter
} from "../../media/storage/adapters/StorageLocalAdapter"; } from "../../media/storage/adapters/StorageLocalAdapter";
export { registerLocalMediaAdapter } from "../index";
export * from "./node.adapter";
export { StorageLocalAdapter, type LocalAdapterConfig };
export function registerLocalMediaAdapter() {
registries.media.register("local", StorageLocalAdapter);
}
+4 -3
View File
@@ -1,9 +1,10 @@
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 { registerLocalMediaAdapter } from "adapter/node/index";
import type { App } from "bknd"; import type { App } from "bknd";
import { config as $config } from "core"; import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { type RuntimeBkndConfig, createRuntimeApp } from "../index"; import { config as $config } from "bknd/core";
export type NodeBkndConfig = RuntimeBkndConfig & { export type NodeBkndConfig = RuntimeBkndConfig & {
port?: number; port?: number;
@@ -37,9 +38,9 @@ export function serve({
hostname, hostname,
fetch: async (req: Request) => { fetch: async (req: Request) => {
if (!app) { if (!app) {
registerLocalMediaAdapter();
app = await createRuntimeApp({ app = await createRuntimeApp({
...config, ...config,
registerLocalMedia: true,
serveStatic: serveStatic({ root }) serveStatic: serveStatic({ root })
}); });
} }
+3 -1
View File
@@ -1,9 +1,11 @@
import { useAuth } from "bknd/client";
import type { BkndAdminProps } from "bknd/ui"; import type { BkndAdminProps } from "bknd/ui";
import { Suspense, lazy, useEffect, useState } from "react"; import { Suspense, lazy, useEffect, useState } from "react";
export function adminPage(props?: BkndAdminProps) { export function adminPage(props?: BkndAdminProps) {
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin }))); const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
return () => { return () => {
const auth = useAuth();
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
return ( return (
<Suspense> <Suspense>
<Admin {...props} /> <Admin withProvider={{ user: auth.user }} {...props} />
</Suspense> </Suspense>
); );
}; };
+29 -6
View File
@@ -1,14 +1,37 @@
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
export type RemixBkndConfig = FrameworkBkndConfig; export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
type RemixContext = {
request: Request;
};
let app: App; let app: App;
export function serve(config: RemixBkndConfig = {}) { let building: boolean = false;
return async (args: { request: Request }) => {
if (!app) { export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
app = await createFrameworkApp(config); if (building) {
while (building) {
await new Promise((resolve) => setTimeout(resolve, 5));
} }
if (app) return app;
}
building = true;
if (!app) {
app = await createFrameworkApp(config, args);
await app.build();
}
building = false;
return app;
}
export function serve<Args extends RemixContext = RemixContext>(
config: RemixBkndConfig<Args> = {}
) {
return async (args: Args) => {
app = await createFrameworkApp(config, args);
return app.fetch(args.request); return app.fetch(args.request);
}; };
} }
+25
View File
@@ -0,0 +1,25 @@
import type { IncomingMessage } from "node:http";
export function nodeRequestToRequest(req: IncomingMessage): Request {
let protocol = "http";
try {
protocol = req.headers["x-forwarded-proto"] as string;
} catch (e) {}
const host = req.headers.host;
const url = `${protocol}://${host}${req.url}`;
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
headers.append(key, value.join(", "));
} else if (value) {
headers.append(key, value);
}
}
const method = req.method || "GET";
return new Request(url, {
method,
headers
});
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { serveStatic } from "@hono/node-server/serve-static"; import { serveStatic } from "@hono/node-server/serve-static";
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server"; import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
import type { App } from "bknd"; import type { App } from "bknd";
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
import { registerLocalMediaAdapter } from "bknd/adapter/node";
import { devServerConfig } from "./dev-server-config"; import { devServerConfig } from "./dev-server-config";
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & { export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
} }
async function createApp(config: ViteBkndConfig = {}, env?: any) { async function createApp(config: ViteBkndConfig = {}, env?: any) {
registerLocalMediaAdapter();
return await createRuntimeApp( return await createRuntimeApp(
{ {
...config, ...config,
registerLocalMedia: true,
adminOptions: adminOptions:
config.setAdminHtml === false config.setAdminHtml === false
? undefined ? undefined
+8 -2
View File
@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async login(strategy: string, input: any) { async login(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "login"], input); const res = await this.post<AuthResponse>([strategy, "login"], input, {
credentials: "include"
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
@@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
} }
async register(strategy: string, input: any) { async register(strategy: string, input: any) {
const res = await this.post<AuthResponse>([strategy, "register"], input); const res = await this.post<AuthResponse>([strategy, "register"], input, {
credentials: "include"
});
if (res.ok && res.body.token) { if (res.ok && res.body.token) {
await this.options.onTokenUpdate?.(res.body.token); await this.options.onTokenUpdate?.(res.body.token);
} }
+14 -3
View File
@@ -21,6 +21,15 @@ export class AuthController extends Controller {
return this.auth.ctx.guard; return this.auth.ctx.guard;
} }
get em() {
return this.auth.ctx.em;
}
get userRepo() {
const entity_name = this.auth.config.entity_name;
return this.em.repo(entity_name as "users");
}
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) { private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
const actions = strategy.getActions?.(); const actions = strategy.getActions?.();
if (!actions) { if (!actions) {
@@ -95,8 +104,10 @@ export class AuthController extends Controller {
} }
hono.get("/me", auth(), async (c) => { hono.get("/me", auth(), async (c) => {
if (this.auth.authenticator.isUserLoggedIn()) { const claims = c.get("auth")?.user;
return c.json({ user: this.auth.authenticator.getUser() }); if (claims) {
const { data: user } = await this.userRepo.findId(claims.id);
return c.json({ user });
} }
return c.json({ user: null }, 403); return c.json({ user: null }, 403);
@@ -121,6 +132,6 @@ export class AuthController extends Controller {
return c.json({ strategies, basepath }); return c.json({ strategies, basepath });
}); });
return hono; return hono.all("*", (c) => c.notFound());
} }
} }
+27 -43
View File
@@ -106,17 +106,15 @@ export type AuthUserResolver = (
identifier: string, identifier: string,
profile: ProfileExchange profile: ProfileExchange
) => Promise<SafeUser | undefined>; ) => Promise<SafeUser | undefined>;
type AuthClaims = SafeUser & {
iat: number;
iss?: string;
exp?: number;
};
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> { export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
private readonly strategies: Strategies; private readonly strategies: Strategies;
private readonly config: AuthConfig; private readonly config: AuthConfig;
private _claims:
| undefined
| (SafeUser & {
iat: number;
iss?: string;
exp?: number;
});
private readonly userResolver: AuthUserResolver; private readonly userResolver: AuthUserResolver;
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) { constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
@@ -148,21 +146,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return this.strategies; return this.strategies;
} }
isUserLoggedIn(): boolean {
return this._claims !== undefined;
}
getUser(): SafeUser | undefined {
if (!this._claims) return;
const { iat, exp, iss, ...user } = this._claims;
return user;
}
resetUser() {
this._claims = undefined;
}
strategy< strategy<
StrategyName extends keyof Strategies, StrategyName extends keyof Strategies,
Strat extends Strategy = Strategies[StrategyName] Strat extends Strategy = Strategies[StrategyName]
@@ -206,7 +189,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
return sign(payload, secret, this.config.jwt?.alg ?? "HS256"); return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
} }
async verify(jwt: string): Promise<boolean> { async verify(jwt: string): Promise<AuthClaims | undefined> {
try { try {
const payload = await verify( const payload = await verify(
jwt, jwt,
@@ -221,14 +204,10 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
this._claims = payload as any; return payload as any;
return true; } catch (e) {}
} catch (e) {
this.resetUser();
//console.error(e);
}
return false; return;
} }
private get cookieOptions(): CookieOptions { private get cookieOptions(): CookieOptions {
@@ -258,8 +237,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
async requestCookieRefresh(c: Context) { async requestCookieRefresh(c: Context<ServerEnv>) {
if (this.config.cookie.renew) { if (this.config.cookie.renew && c.get("auth")?.user) {
const token = await this.getAuthCookie(c); const token = await this.getAuthCookie(c);
if (token) { if (token) {
await this.setAuthCookie(c, token); await this.setAuthCookie(c, token);
@@ -276,13 +255,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
await deleteCookie(c, "auth", this.cookieOptions); await deleteCookie(c, "auth", this.cookieOptions);
} }
async logout(c: Context) { async logout(c: Context<ServerEnv>) {
c.set("auth", undefined);
const cookie = await this.getAuthCookie(c); const cookie = await this.getAuthCookie(c);
if (cookie) { if (cookie) {
await this.deleteAuthCookie(c); await this.deleteAuthCookie(c);
await addFlashMessage(c, "Signed out", "info"); await addFlashMessage(c, "Signed out", "info");
} }
this.resetUser();
} }
// @todo: move this to a server helper // @todo: move this to a server helper
@@ -299,8 +279,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
} }
private getSuccessPath(c: Context) { private getSafeUrl(c: Context, path: string) {
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/"); const p = path.replace(/\/+$/, "/");
// nextjs doesn't support non-fq urls // nextjs doesn't support non-fq urls
// but env could be proxied (stackblitz), so we shouldn't fq every url // but env could be proxied (stackblitz), so we shouldn't fq every url
@@ -312,21 +292,26 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) { async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
if (this.isJsonRequest(c)) { const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
return c.json(data);
}
const successUrl = this.getSuccessPath(c);
const referer = redirect ?? c.req.header("Referer") ?? successUrl; const referer = redirect ?? c.req.header("Referer") ?? successUrl;
//console.log("auth respond", { redirect, successUrl, successPath }); //console.log("auth respond", { redirect, successUrl, successPath });
if ("token" in data) { if ("token" in data) {
await this.setAuthCookie(c, data.token); await this.setAuthCookie(c, data.token);
if (this.isJsonRequest(c)) {
return c.json(data);
}
// can't navigate to "/" doesn't work on nextjs // can't navigate to "/" doesn't work on nextjs
//console.log("auth success, redirecting to", successUrl); //console.log("auth success, redirecting to", successUrl);
return c.redirect(successUrl); return c.redirect(successUrl);
} }
if (this.isJsonRequest(c)) {
return c.json(data, 400);
}
let message = "An error occured"; let message = "An error occured";
if (data instanceof Exception) { if (data instanceof Exception) {
message = data.message; message = data.message;
@@ -348,8 +333,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
} }
if (token) { if (token) {
await this.verify(token); return await this.verify(token);
return this.getUser();
} }
return undefined; return undefined;
@@ -1,4 +1,5 @@
import type { Authenticator, Strategy } from "auth"; import type { Authenticator, Strategy } from "auth";
import { isDebug, tbValidator as tb } from "core";
import { type Static, StringEnum, Type, parse } from "core/utils"; import { type Static, StringEnum, Type, parse } from "core/utils";
import { hash } from "core/utils"; import { hash } from "core/utils";
import { type Context, Hono } from "hono"; import { type Context, Hono } from "hono";
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
const hono = new Hono(); const hono = new Hono();
return hono return hono
.post("/login", async (c) => { .post(
const body = await authenticator.getBody(c); "/login",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String())
})
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
try { try {
const payload = await this.login(body); const payload = await this.login(body);
const data = await authenticator.resolve("login", this, payload.password, payload); const data = await authenticator.resolve(
"login",
this,
payload.password,
payload
);
return await authenticator.respond(c, data); return await authenticator.respond(c, data, redirect);
} catch (e) { } catch (e) {
return await authenticator.respond(c, e); return await authenticator.respond(c, e);
}
} }
}) )
.post("/register", async (c) => { .post(
const body = await authenticator.getBody(c); "/register",
tb(
"query",
Type.Object({
redirect: Type.Optional(Type.String())
})
),
async (c) => {
const body = await authenticator.getBody(c);
const { redirect } = c.req.valid("query");
const payload = await this.register(body); const payload = await this.register(body);
const data = await authenticator.resolve("register", this, payload.password, payload); const data = await authenticator.resolve(
"register",
this,
payload.password,
payload
);
return await authenticator.respond(c, data); return await authenticator.respond(c, data, redirect);
}); }
);
} }
getActions(): StrategyActions { getActions(): StrategyActions {
+19 -21
View File
@@ -1,21 +1,23 @@
import { Exception, Permission } from "core"; import { Exception, Permission } from "core";
import { objectTransform } from "core/utils"; import { objectTransform } from "core/utils";
import type { Context } from "hono";
import type { ServerEnv } from "modules/Module";
import { Role } from "./Role"; import { Role } from "./Role";
export type GuardUserContext = { export type GuardUserContext = {
role: string | null | undefined; role?: string | null;
[key: string]: any; [key: string]: any;
}; };
export type GuardConfig = { export type GuardConfig = {
enabled?: boolean; enabled?: boolean;
}; };
export type GuardContext = Context<ServerEnv> | GuardUserContext;
const debug = false; const debug = false;
export class Guard { export class Guard {
permissions: Permission[]; permissions: Permission[];
user?: GuardUserContext;
roles?: Role[]; roles?: Role[];
config?: GuardConfig; config?: GuardConfig;
@@ -89,24 +91,19 @@ export class Guard {
return this; return this;
} }
setUserContext(user: GuardUserContext | undefined) { getUserRole(user?: GuardUserContext): Role | undefined {
this.user = user; if (user && typeof user.role === "string") {
return this; const role = this.roles?.find((role) => role.name === user?.role);
}
getUserRole(): Role | undefined {
if (this.user && typeof this.user.role === "string") {
const role = this.roles?.find((role) => role.name === this.user?.role);
if (role) { if (role) {
debug && console.log("guard: role found", [this.user.role]); debug && console.log("guard: role found", [user.role]);
return role; return role;
} }
} }
debug && debug &&
console.log("guard: role not found", { console.log("guard: role not found", {
user: this.user, user: user,
role: this.user?.role role: user?.role
}); });
return this.getDefaultRole(); return this.getDefaultRole();
} }
@@ -119,9 +116,9 @@ export class Guard {
return this.config?.enabled === true; return this.config?.enabled === true;
} }
hasPermission(permission: Permission): boolean; hasPermission(permission: Permission, user?: GuardUserContext): boolean;
hasPermission(name: string): boolean; hasPermission(name: string, user?: GuardUserContext): boolean;
hasPermission(permissionOrName: Permission | string): boolean { hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
if (!this.isEnabled()) { if (!this.isEnabled()) {
//console.log("guard not enabled, allowing"); //console.log("guard not enabled, allowing");
return true; return true;
@@ -133,7 +130,7 @@ export class Guard {
throw new Error(`Permission ${name} does not exist`); throw new Error(`Permission ${name} does not exist`);
} }
const role = this.getUserRole(); const role = this.getUserRole(user);
if (!role) { if (!role) {
debug && console.log("guard: role not found, denying"); debug && console.log("guard: role not found, denying");
@@ -156,12 +153,13 @@ export class Guard {
return !!rolePermission; return !!rolePermission;
} }
granted(permission: Permission | string): boolean { granted(permission: Permission | string, c?: GuardContext): boolean {
return this.hasPermission(permission as any); const user = c && "get" in c ? c.get("auth")?.user : c;
return this.hasPermission(permission as any, user);
} }
throwUnlessGranted(permission: Permission | string) { throwUnlessGranted(permission: Permission | string, c: GuardContext) {
if (!this.granted(permission)) { if (!this.granted(permission, c)) {
throw new Exception( throw new Exception(
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`, `Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
403 403
+33 -23
View File
@@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) {
} }
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) { export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
if (c.get("auth_skip")) return true; const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
if (authCtx.skip) return true;
const req = c.req.raw; const req = c.req.raw;
if (!skip) return false; if (!skip) return false;
@@ -18,7 +23,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
const path = getPath(req); const path = getPath(req);
const result = skip.some((s) => patternMatch(path, s)); const result = skip.some((s) => patternMatch(path, s));
c.set("auth_skip", result); authCtx.skip = result;
return result; return result;
} }
@@ -26,29 +31,31 @@ export const auth = (options?: {
skip?: (string | RegExp)[]; skip?: (string | RegExp)[];
}) => }) =>
createMiddleware<ServerEnv>(async (c, next) => { createMiddleware<ServerEnv>(async (c, next) => {
if (!c.get("auth")) {
c.set("auth", {
registered: false,
resolved: false,
skip: false,
user: undefined
});
}
const app = c.get("app"); const app = c.get("app");
const guard = app?.modules.ctx().guard; const authCtx = c.get("auth")!;
const authenticator = app?.module.auth.authenticator; const authenticator = app?.module.auth.authenticator;
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled; let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
// make sure to only register once // make sure to only register once
if (c.get("auth_registered")) { if (authCtx.registered) {
skipped = true; skipped = true;
console.warn(`auth middleware already registered for ${getPath(c)}`); console.warn(`auth middleware already registered for ${getPath(c)}`);
} else { } else {
c.set("auth_registered", true); authCtx.registered = true;
if (!skipped) { if (!skipped && !authCtx.resolved && app?.module.auth.enabled) {
const resolved = c.get("auth_resolved"); authCtx.user = await authenticator?.resolveAuthFromRequest(c);
if (!resolved) { authCtx.resolved = true;
if (!app?.module.auth.enabled) {
guard?.setUserContext(undefined);
} else {
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
c.set("auth_resolved", true);
}
}
} }
} }
@@ -60,9 +67,9 @@ export const auth = (options?: {
} }
// release // release
guard?.setUserContext(undefined); authCtx.skip = false;
authenticator?.resetUser(); authCtx.resolved = false;
c.set("auth_resolved", false); authCtx.user = undefined;
}); });
export const permission = ( export const permission = (
@@ -75,23 +82,26 @@ export const permission = (
// @ts-ignore // @ts-ignore
createMiddleware<ServerEnv>(async (c, next) => { createMiddleware<ServerEnv>(async (c, next) => {
const app = c.get("app"); const app = c.get("app");
//console.log("skip?", c.get("auth_skip")); const authCtx = c.get("auth");
if (!authCtx) {
throw new Error("auth ctx not found");
}
// in tests, app is not defined // in tests, app is not defined
if (!c.get("auth_registered") || !app) { if (!authCtx.registered || !app) {
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`; const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
if (app?.module.auth.enabled) { if (app?.module.auth.enabled) {
throw new Error(msg); throw new Error(msg);
} else { } else {
console.warn(msg); console.warn(msg);
} }
} else if (!c.get("auth_skip")) { } else if (!authCtx.skip) {
const guard = app.modules.ctx().guard; const guard = app.modules.ctx().guard;
const permissions = Array.isArray(permission) ? permission : [permission]; const permissions = Array.isArray(permission) ? permission : [permission];
if (options?.onGranted || options?.onDenied) { if (options?.onGranted || options?.onDenied) {
let returned: undefined | void | Response; let returned: undefined | void | Response;
if (permissions.every((p) => guard.granted(p))) { if (permissions.every((p) => guard.granted(p, c))) {
returned = await options?.onGranted?.(c); returned = await options?.onGranted?.(c);
} else { } else {
returned = await options?.onDenied?.(c); returned = await options?.onDenied?.(c);
@@ -100,7 +110,7 @@ export const permission = (
return returned; return returned;
} }
} else { } else {
permissions.some((p) => guard.throwUnlessGranted(p)); permissions.some((p) => guard.throwUnlessGranted(p, c));
} }
} }
+294
View File
@@ -0,0 +1,294 @@
import fs from "node:fs";
import { downloadTemplate } from "@bluwy/giget-core";
import * as $p from "@clack/prompts";
import type { CliCommand } from "cli/types";
import { typewriter, wait } from "cli/utils/cli";
import { execAsync, getVersion } from "cli/utils/sys";
import { Option } from "commander";
import { colorizeConsole } from "core";
import color from "picocolors";
import { overridePackageJson, updateBkndPackages } from "./npm";
import { type Template, templates } from "./templates";
const config = {
types: {
runtime: "Runtime",
framework: "Framework"
},
runtime: {
node: "Node.js",
bun: "Bun",
cloudflare: "Cloudflare"
},
framework: {
nextjs: "Next.js",
remix: "Remix",
astro: "Astro"
}
} as const;
export const create: CliCommand = (program) => {
program
.command("create")
.addOption(new Option("-i, --integration <integration>", "integration to use"))
.addOption(new Option("-t, --template <template>", "template to use"))
.addOption(new Option("-d --dir <directory>", "directory to create in"))
.description("create a new project")
.action(action);
};
function errorOutro() {
$p.outro(color.red("Failed to create project."));
console.log(
color.yellow("Sorry that this happened. If you think this is a bug, please report it at: ") +
color.cyan("https://github.com/bknd-io/bknd/issues")
);
console.log("");
process.exit(1);
}
async function action(options: { template?: string; dir?: string; integration?: string }) {
console.log("");
colorizeConsole(console);
const downloadOpts = {
dir: options.dir || "./",
clean: false
};
const version = await getVersion();
$p.intro(
`👋 Welcome to the ${color.bold(color.cyan("bknd"))} create wizard ${color.bold(`v${version}`)}`
);
await $p.stream.message(
(async function* () {
yield* typewriter("Thanks for choosing to create a new project with bknd!", color.dim);
await wait();
})()
);
if (!options.dir) {
const dir = await $p.text({
message: "Where to create your project?",
placeholder: downloadOpts.dir,
initialValue: downloadOpts.dir
});
if ($p.isCancel(dir)) {
process.exit(1);
}
downloadOpts.dir = dir || "./";
}
if (fs.existsSync(downloadOpts.dir)) {
const clean = await $p.confirm({
message: `Directory ${color.cyan(downloadOpts.dir)} exists. Clean it?`,
initialValue: false
});
if ($p.isCancel(clean)) {
process.exit(1);
}
downloadOpts.clean = clean;
}
let name = downloadOpts.dir.includes("/")
? downloadOpts.dir.split("/").pop()
: downloadOpts.dir.replace(/[./]/g, "");
if (!name || name.length === 0) name = "bknd";
let template: Template | undefined;
if (options.template) {
template = templates.find((t) => t.key === options.template) as Template;
if (!template) {
$p.log.error(`Template ${color.cyan(options.template)} not found`);
process.exit(1);
}
} else {
let integration: string | undefined = options.integration;
if (!integration) {
await $p.stream.info(
(async function* () {
yield* typewriter("Ready? ", color.bold, 1.5);
await wait(2);
yield* typewriter("Let's find the perfect template for you.", color.dim);
await wait(2);
})()
);
const type = await $p.select({
message: "Pick an integration type",
options: Object.entries(config.types).map(([value, name]) => ({
value,
label: name,
hint: Object.values(config[value]).join(", ")
}))
});
if ($p.isCancel(type)) {
process.exit(1);
}
const _integration = await $p.select({
message: `Which ${color.cyan(config.types[type])} do you want to continue with?`,
options: Object.entries(config[type]).map(([value, name]) => ({
value,
label: name
})) as any
});
if ($p.isCancel(_integration)) {
process.exit(1);
}
integration = String(_integration);
}
if (!integration) {
$p.log.error("No integration selected");
process.exit(1);
}
//console.log("integration", { type, integration });
const choices = templates.filter((t) => t.integration === integration);
if (choices.length === 0) {
$p.log.error(`No templates found for "${color.cyan(String(integration))}"`);
process.exit(1);
} else if (choices.length > 1) {
const selected_template = await $p.select({
message: "Pick a template",
options: choices.map((t) => ({ value: t.key, label: t.title, hint: t.description }))
});
if ($p.isCancel(selected_template)) {
process.exit(1);
}
template = choices.find((t) => t.key === selected_template) as Template;
} else {
template = choices[0];
}
}
if (!template) {
$p.log.error("No template selected");
process.exit(1);
}
const ctx = { template, dir: downloadOpts.dir, name };
{
const ref = process.env.BKND_CLI_CREATE_REF ?? `v${version}`;
if (process.env.BKND_CLI_CREATE_REF) {
$p.log.warn(color.dim("[DEV] Using local ref: ") + color.yellow(ref));
}
const prefix =
template.ref === true
? `#${ref}`
: typeof template.ref === "string"
? `#${template.ref}`
: "";
const url = `${template.path}${prefix}`;
//console.log("url", url);
const s = $p.spinner();
await s.start("Downloading template...");
try {
await downloadTemplate(url, {
dir: ctx.dir,
force: downloadOpts.clean ? "clean" : true
});
} catch (e) {
if (e instanceof Error) {
s.stop("Failed to download template: " + color.red(e.message), 1);
} else {
console.error(e);
s.stop("Failed to download template. Check logs above.", 1);
}
errorOutro();
}
s.stop("Template downloaded.");
await updateBkndPackages(ctx.dir);
if (template.preinstall) {
await template.preinstall(ctx);
}
}
// update package name
await overridePackageJson(
(pkg) => ({
...pkg,
name: ctx.name
}),
{ dir: ctx.dir }
);
$p.log.success(`Updated package name to ${color.cyan(ctx.name)}`);
{
const install = await $p.confirm({
message: "Install dependencies?"
});
if ($p.isCancel(install)) {
process.exit(1);
} else if (install) {
const install_cmd = template.scripts?.install || "npm install";
const s = $p.spinner();
await s.start("Installing dependencies...");
try {
await execAsync(`cd ${ctx.dir} && ${install_cmd}`, { silent: true });
} catch (e) {
if (e instanceof Error) {
s.stop("Failed to install: " + color.red(e.message), 1);
} else {
console.error(e);
s.stop("Failed to install. Check logs above.", 1);
}
errorOutro();
}
s.stop("Dependencies installed.");
if (template!.postinstall) {
await template.postinstall(ctx);
}
} else {
await $p.stream.warn(
(async function* () {
yield* typewriter(
color.dim("Remember to run ") +
color.cyan("npm install") +
color.dim(" after setup")
);
await wait();
})()
);
}
}
if (template.setup) {
await template.setup(ctx);
}
await $p.stream.success(
(async function* () {
yield* typewriter("That's it! ");
await wait(0.5);
yield "🎉";
await wait();
yield "\n\n";
yield* typewriter(
`Enter your project's directory using ${color.cyan("cd " + ctx.dir)}
If you need help, check ${color.cyan("https://docs.bknd.io")} or join our Discord!`
);
await wait(2);
})()
);
$p.outro(color.green("Setup complete."));
}
+1
View File
@@ -0,0 +1 @@
export * from "./create";
+83
View File
@@ -0,0 +1,83 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { getVersion as sysGetVersion } from "cli/utils/sys";
export type TPackageJson = Partial<{
name: string;
main: string;
version: string;
scripts: Record<string, string>;
dependencies: Record<string, string | undefined>;
devDependencies: Record<string, string | undefined>;
optionalDependencies: Record<string, string | undefined>;
[key: string]: any;
}>;
export async function overrideJson<File extends object = object>(
file: string,
fn: (pkg: File) => Promise<File> | File,
opts?: { dir?: string; indent?: number }
) {
const pkgPath = path.resolve(opts?.dir ?? process.cwd(), file);
const pkg = await readFile(pkgPath, "utf-8");
const newPkg = await fn(JSON.parse(pkg));
await writeFile(pkgPath, JSON.stringify(newPkg, null, opts?.indent || 2));
}
export async function overridePackageJson(
fn: (pkg: TPackageJson) => Promise<TPackageJson> | TPackageJson,
opts?: { dir?: string }
) {
return await overrideJson("package.json", fn, { dir: opts?.dir });
}
export async function getPackageInfo(pkg: string, version?: string): Promise<TPackageJson> {
const res = await fetch(`https://registry.npmjs.org/${pkg}${version ? `/${version}` : ""}`);
return await res.json();
}
export async function getVersion(pkg: string, version: string = "latest") {
const info = await getPackageInfo(pkg, version);
return info.version;
}
const _deps = ["dependencies", "devDependencies", "optionalDependencies"] as const;
export async function replacePackageJsonVersions(
fn: (pkg: string, version: string) => Promise<string | undefined> | string | undefined,
opts?: { include?: (keyof typeof _deps)[]; dir?: string }
) {
const deps = (opts?.include ?? _deps) as string[];
await overridePackageJson(
async (json) => {
for (const dep of deps) {
if (dep in json) {
for (const [pkg, version] of Object.entries(json[dep])) {
const newVersion = await fn(pkg, version as string);
if (newVersion) {
json[dep][pkg] = newVersion;
}
}
}
}
return json;
},
{ dir: opts?.dir }
);
}
export async function updateBkndPackages(dir?: string, map?: Record<string, string>) {
const versions = {
bknd: "^" + (await sysGetVersion()),
...(map ?? {})
};
await replacePackageJsonVersions(
async (pkg) => {
if (pkg in versions) {
return versions[pkg];
}
return;
},
{ dir }
);
}
@@ -0,0 +1,144 @@
import * as $p from "@clack/prompts";
import { overrideJson, overridePackageJson } from "cli/commands/create/npm";
import { typewriter, wait } from "cli/utils/cli";
import { uuid } from "core/utils";
import c from "picocolors";
import type { Template, TemplateSetupCtx } from ".";
const WRANGLER_FILE = "wrangler.json";
export const cloudflare = {
key: "cloudflare",
title: "Cloudflare Basic",
integration: "cloudflare",
description: "A basic bknd Cloudflare worker",
path: "gh:bknd-io/bknd/examples/cloudflare-worker",
ref: true,
setup: async (ctx) => {
// overwrite assets directory & name
await overrideJson(
WRANGLER_FILE,
(json) => ({
...json,
name: ctx.name,
assets: {
directory: "node_modules/bknd/dist/static"
}
}),
{ dir: ctx.dir }
);
const db = await $p.select({
message: "What database do you want to use?",
options: [
{ label: "Cloudflare D1", value: "d1" },
{ label: "LibSQL", value: "libsql" }
]
});
if ($p.isCancel(db)) {
process.exit(1);
}
try {
switch (db) {
case "d1":
await createD1(ctx);
break;
case "libsql":
await createLibsql(ctx);
break;
default:
throw new Error("Invalid database");
}
} catch (e) {
const message = (e as any).message || "An error occurred";
$p.log.warn(
"Couldn't add database. You can add it manually later. Error: " + c.red(message)
);
}
}
} as const satisfies Template;
async function createD1(ctx: TemplateSetupCtx) {
const name = await $p.text({
message: "Enter database name",
initialValue: "data",
placeholder: "data",
validate: (v) => {
if (!v) {
return "Invalid name";
}
return;
}
});
if ($p.isCancel(name)) {
process.exit(1);
}
await overrideJson(
WRANGLER_FILE,
(json) => ({
...json,
d1_databases: [
{
binding: "DB",
database_name: name,
database_id: uuid()
}
]
}),
{ dir: ctx.dir }
);
await $p.stream.info(
(async function* () {
yield* typewriter(`Database added to ${c.cyan("wrangler.json")}`);
await wait();
yield* typewriter(
`\nNote that if you deploy, you have to create a real database using ${c.cyan("npx wrangler d1 create <name>")} and update your wrangler configuration.`,
c.dim
);
})()
);
}
async function createLibsql(ctx: TemplateSetupCtx) {
await overrideJson(
WRANGLER_FILE,
(json) => ({
...json,
vars: {
DB_URL: "http://127.0.0.1:8080"
}
}),
{ dir: ctx.dir }
);
await overridePackageJson(
(pkg) => ({
...pkg,
scripts: {
...pkg.scripts,
db: "turso dev",
dev: "npm run db && wrangler dev"
}
}),
{ dir: ctx.dir }
);
await $p.stream.info(
(async function* () {
yield* typewriter("Database set to LibSQL");
await wait();
yield* typewriter(
`\nYou can now run ${c.cyan("npm run db")} to start the database and ${c.cyan("npm run dev")} to start the worker.`,
c.dim
);
await wait();
yield* typewriter(
`\nAlso make sure you have Turso's CLI installed. Check their docs on how to install at ${c.cyan("https://docs.turso.tech/cli/introduction")}`,
c.dim
);
})()
);
}
@@ -0,0 +1,64 @@
import { cloudflare } from "./cloudflare";
import { nextjs } from "./nextjs";
import { remix } from "./remix";
export type TemplateSetupCtx = {
template: Template;
dir: string;
name: string;
};
export type Integration = "node" | "bun" | "cloudflare" | "nextjs" | "remix" | "astro" | "custom";
type TemplateScripts = "install" | "dev" | "build" | "start";
export type Template = {
/**
* unique key for the template
*/
key: string;
/**
* the integration this template is for
*/
integration: Integration;
title: string;
description?: string;
path: string;
/**
* adds a ref "#{ref}" to the path. If "true", adds the current version of bknd
*/
ref?: true | string;
scripts?: Partial<Record<TemplateScripts, string>>;
preinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
postinstall?: (ctx: TemplateSetupCtx) => Promise<void>;
setup?: (ctx: TemplateSetupCtx) => Promise<void>;
};
export const templates: Template[] = [
cloudflare,
nextjs,
remix,
{
key: "node",
title: "Node.js Basic",
integration: "node",
description: "A basic bknd Node.js server",
path: "gh:bknd-io/bknd/examples/node",
ref: true
},
{
key: "bun",
title: "Bun Basic",
integration: "bun",
description: "A basic bknd Bun server",
path: "gh:bknd-io/bknd/examples/bun",
ref: true
},
{
key: "astro",
title: "Astro Basic",
integration: "astro",
description: "A basic bknd Astro starter",
path: "gh:bknd-io/bknd/examples/astro",
ref: true
}
];
@@ -0,0 +1,29 @@
import { overridePackageJson } from "cli/commands/create/npm";
import type { Template } from ".";
// @todo: add `concurrently`?
export const nextjs = {
key: "nextjs",
title: "Next.js Basic",
integration: "nextjs",
description: "A basic bknd Next.js starter",
path: "gh:bknd-io/bknd/examples/nextjs",
scripts: {
install: "npm install --force"
},
ref: true,
preinstall: async (ctx) => {
// locally it's required to overwrite react, here it is not
await overridePackageJson(
(pkg) => ({
...pkg,
dependencies: {
...pkg.dependencies,
react: undefined,
"react-dom": undefined
}
}),
{ dir: ctx.dir }
);
}
} as const satisfies Template;
@@ -0,0 +1,25 @@
import { overridePackageJson } from "cli/commands/create/npm";
import type { Template } from ".";
export const remix = {
key: "remix",
title: "Remix Basic",
integration: "remix",
description: "A basic bknd Remix starter",
path: "gh:bknd-io/bknd/examples/remix",
ref: true,
preinstall: async (ctx) => {
// locally it's required to overwrite react
await overridePackageJson(
(pkg) => ({
...pkg,
dependencies: {
...pkg.dependencies,
react: "^18.2.0",
"react-dom": "^18.2.0"
}
}),
{ dir: ctx.dir }
);
}
} as const satisfies Template;
+35 -11
View File
@@ -1,20 +1,44 @@
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
import { createApp } from "App";
import { getConnectionCredentialsFromEnv } from "cli/commands/run/platform";
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys"; import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
import { Argument } from "commander";
import { showRoutes } from "hono/dev";
import type { CliCommand } from "../types"; import type { CliCommand } from "../types";
export const debug: CliCommand = (program) => { export const debug: CliCommand = (program) => {
program program
.command("debug") .command("debug")
.description("debug path resolution") .description("debug bknd")
.action(() => { .addArgument(new Argument("<subject>", "subject to debug").choices(Object.keys(subjects)))
console.log("paths", { .action(action);
rootpath: getRootPath(),
distPath: getDistPath(),
relativeDistPath: getRelativeDistPath(),
cwd: process.cwd(),
dir: path.dirname(url.fileURLToPath(import.meta.url)),
resolvedPkg: path.resolve(getRootPath(), "package.json")
});
});
}; };
const subjects = {
paths: async () => {
console.log("[PATHS]", {
rootpath: getRootPath(),
distPath: getDistPath(),
relativeDistPath: getRelativeDistPath(),
cwd: process.cwd(),
dir: path.dirname(url.fileURLToPath(import.meta.url)),
resolvedPkg: path.resolve(getRootPath(), "package.json")
});
},
routes: async () => {
console.log("[APP ROUTES]");
const credentials = getConnectionCredentialsFromEnv();
const app = createApp({ connection: credentials });
await app.build();
showRoutes(app.server);
}
};
async function action(subject: string) {
if (subject in subjects) {
await subjects[subject]();
} else {
console.error("Invalid subject: ", subject);
}
}
+1
View File
@@ -3,3 +3,4 @@ export { schema } from "./schema";
export { run } from "./run"; export { run } from "./run";
export { debug } from "./debug"; export { debug } from "./debug";
export { user } from "./user"; export { user } from "./user";
export { create } from "./create";
+8 -2
View File
@@ -32,7 +32,7 @@ export async function attachServeStatic(app: any, platform: Platform) {
export async function startServer(server: Platform, app: any, options: { port: number }) { export async function startServer(server: Platform, app: any, options: { port: number }) {
const port = options.port; const port = options.port;
console.log(`(using ${server} serve)`); console.log(`Using ${server} serve`);
switch (server) { switch (server) {
case "node": { case "node": {
@@ -54,7 +54,7 @@ export async function startServer(server: Platform, app: any, options: { port: n
} }
const url = `http://localhost:${port}`; const url = `http://localhost:${port}`;
console.log(`Server listening on ${url}`); console.info("Server listening on", url);
await open(url); await open(url);
} }
@@ -76,3 +76,9 @@ export async function getConfigPath(filePath?: string) {
return; return;
} }
export function getConnectionCredentialsFromEnv() {
const dbUrl = process.env.DB_URL;
const dbToken = process.env.DB_TOKEN;
return dbUrl ? { url: dbUrl, authToken: dbToken } : undefined;
}
+38 -6
View File
@@ -3,16 +3,20 @@ import { App, type CreateAppConfig } from "App";
import { StorageLocalAdapter } from "adapter/node"; import { StorageLocalAdapter } from "adapter/node";
import type { CliBkndConfig, CliCommand } from "cli/types"; import type { CliBkndConfig, CliCommand } from "cli/types";
import { Option } from "commander"; import { Option } from "commander";
import { config } from "core"; import { colorizeConsole, config } from "core";
import dotenv from "dotenv";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
import c from "picocolors";
import { import {
PLATFORMS, PLATFORMS,
type Platform, type Platform,
attachServeStatic, attachServeStatic,
getConfigPath, getConfigPath,
getConnectionCredentialsFromEnv,
startServer startServer
} from "./platform"; } from "./platform";
dotenv.config();
const isBun = typeof Bun !== "undefined"; const isBun = typeof Bun !== "undefined";
export const run: CliCommand = (program) => { export const run: CliCommand = (program) => {
@@ -24,6 +28,13 @@ export const run: CliCommand = (program) => {
.default(config.server.default_port) .default(config.server.default_port)
.argParser((v) => Number.parseInt(v)) .argParser((v) => Number.parseInt(v))
) )
.addOption(
new Option("-m, --memory", "use in-memory database").conflicts([
"config",
"db-url",
"db-token"
])
)
.addOption(new Option("-c, --config <config>", "config file")) .addOption(new Option("-c, --config <config>", "config file"))
.addOption( .addOption(
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts( new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
@@ -94,23 +105,44 @@ export async function makeConfigApp(config: CliBkndConfig, platform?: Platform)
async function action(options: { async function action(options: {
port: number; port: number;
memory?: boolean;
config?: string; config?: string;
dbUrl?: string; dbUrl?: string;
dbToken?: string; dbToken?: string;
server: Platform; server: Platform;
}) { }) {
colorizeConsole(console);
const configFilePath = await getConfigPath(options.config); const configFilePath = await getConfigPath(options.config);
let app: App; let app: App | undefined = undefined;
if (options.dbUrl || !configFilePath) { if (options.dbUrl) {
console.info("Using connection from", c.cyan("--db-url"));
const connection = options.dbUrl const connection = options.dbUrl
? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } } ? { url: options.dbUrl, authToken: options.dbToken }
: undefined; : undefined;
app = await makeApp({ connection, server: { platform: options.server } }); app = await makeApp({ connection, server: { platform: options.server } });
} else { } else if (configFilePath) {
console.log("Using config from:", configFilePath); console.info("Using config from", c.cyan(configFilePath));
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig; const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
app = await makeConfigApp(config, options.server); app = await makeConfigApp(config, options.server);
} else if (options.memory) {
console.info("Using", c.cyan("in-memory"), "connection");
app = await makeApp({ server: { platform: options.server } });
} else {
const credentials = getConnectionCredentialsFromEnv();
if (credentials) {
console.info("Using connection from env", c.cyan(credentials.url));
app = await makeConfigApp({ app: { connection: credentials } }, options.server);
}
}
if (!app) {
const connection = { url: "file:data.db" } as Config;
console.info("Using connection", c.cyan(connection.url));
app = await makeApp({
connection,
server: { platform: options.server }
});
} }
await startServer(options.server, app, { port: options.port }); await startServer(options.server, app, { port: options.port });
+4 -2
View File
@@ -1,15 +1,17 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Command } from "commander"; import { Command } from "commander";
import color from "picocolors";
import * as commands from "./commands"; import * as commands from "./commands";
import { getVersion } from "./utils/sys"; import { getVersion } from "./utils/sys";
const program = new Command(); const program = new Command();
export async function main() { export async function main() {
const version = await getVersion();
program program
.name("bknd") .name("bknd")
.description("bknd cli") .description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`)))
.version(await getVersion()); .version(version);
// register commands // register commands
for (const command of Object.values(commands)) { for (const command of Object.values(commands)) {
+56
View File
@@ -0,0 +1,56 @@
const _SPEEDUP = process.env.LOCAL;
const DEFAULT_WAIT = _SPEEDUP ? 0 : 250;
export async function wait(factor: number = 1, strict?: boolean) {
const ms = strict === true ? factor : DEFAULT_WAIT * factor;
return new Promise((r) => setTimeout(r, ms));
}
// from https://github.com/chalk/ansi-regex/blob/main/index.js
export default function ansiRegex({ onlyFirst = false } = {}) {
// Valid string terminator sequences are BEL, ESC\, and 0x9c
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
const pattern = [
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
].join("|");
return new RegExp(pattern, onlyFirst ? undefined : "g");
}
const DEFAULT_WAIT_WRITER = _SPEEDUP ? 0 : 20;
export async function* typewriter(
text: string,
transform?: (char: string) => string,
_delay?: number
) {
const delay = DEFAULT_WAIT_WRITER * (_delay ?? 1);
const regex = ansiRegex();
const parts: string[] = [];
let match: RegExpExecArray | null;
let lastIndex = 0;
// Extract ANSI escape sequences as standalone units
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
while ((match = regex.exec(text)) !== null) {
if (lastIndex < match.index) {
parts.push(...text.slice(lastIndex, match.index).split(""));
}
parts.push(match[0]); // Add the ANSI escape sequence as a full chunk
lastIndex = regex.lastIndex;
}
// Add any remaining characters after the last ANSI sequence
if (lastIndex < text.length) {
parts.push(...text.slice(lastIndex).split(""));
}
// Yield characters or ANSI sequences in order
for (const chunk of parts) {
yield transform ? transform(chunk) : chunk;
// Delay only for normal characters, not ANSI codes
if (!regex.test(chunk)) {
await wait(delay, true);
}
}
}
+35 -2
View File
@@ -1,3 +1,4 @@
import { execSync, exec as nodeExec } from "node:child_process";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import url from "node:url"; import url from "node:url";
@@ -16,9 +17,9 @@ export function getRelativeDistPath() {
return path.relative(process.cwd(), getDistPath()); return path.relative(process.cwd(), getDistPath());
} }
export async function getVersion() { export async function getVersion(_path: string = "") {
try { try {
const resolved = path.resolve(getRootPath(), "package.json"); const resolved = path.resolve(getRootPath(), path.join(_path, "package.json"));
const pkg = await readFile(resolved, "utf-8"); const pkg = await readFile(resolved, "utf-8");
if (pkg) { if (pkg) {
return JSON.parse(pkg).version ?? "preview"; return JSON.parse(pkg).version ?? "preview";
@@ -38,3 +39,35 @@ export async function fileExists(filePath: string) {
return false; return false;
} }
} }
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
const stdio = opts?.silent ? "pipe" : "inherit";
const output = execSync(command, {
stdio: ["inherit", stdio, stdio],
env: { ...process.env, ...opts?.env }
});
if (!opts?.silent) {
return;
}
return output.toString();
}
export function execAsync(
command: string,
opts?: { silent?: boolean; env?: Record<string, string> }
) {
return new Promise((resolve, reject) => {
nodeExec(
command,
{
env: { ...process.env, ...opts?.env }
},
(err, stdout, stderr) => {
if (err) {
return reject(err);
}
resolve(stdout);
}
);
});
}
+105
View File
@@ -0,0 +1,105 @@
import colors from "picocolors";
function hasColors() {
try {
// biome-ignore lint/style/useSingleVarDeclarator: <explanation>
const p = process || {},
argv = p.argv || [],
env = p.env || {};
return (
!(!!env.NO_COLOR || argv.includes("--no-color")) &&
// biome-ignore lint/complexity/useOptionalChain: <explanation>
(!!env.FORCE_COLOR ||
argv.includes("--color") ||
p.platform === "win32" ||
((p.stdout || {}).isTTY && env.TERM !== "dumb") ||
!!env.CI)
);
} catch (e) {
return false;
}
}
const originalConsoles = {
error: console.error,
warn: console.warn,
info: console.info,
log: console.log,
debug: console.debug
} as typeof console;
function __tty(type: any, args: any[]) {
const has = hasColors();
const styles = {
error: {
prefix: colors.red,
args: colors.red
},
warn: {
prefix: colors.yellow,
args: colors.yellow
},
info: {
prefix: colors.cyan
},
log: {
prefix: colors.gray
},
debug: {
prefix: colors.yellow
}
} as const;
const prefix = styles[type].prefix(
`[${type.toUpperCase()}]${has ? " ".repeat(5 - type.length) : ""}`
);
const _args = args.map((a) =>
"args" in styles[type] && has && typeof a === "string" ? styles[type].args(a) : a
);
return originalConsoles[type](prefix, ..._args);
}
export type TConsoleSeverity = keyof typeof originalConsoles;
const severities = Object.keys(originalConsoles) as TConsoleSeverity[];
let enabled = [...severities];
export function disableConsole(severities: TConsoleSeverity[] = enabled) {
enabled = enabled.filter((s) => !severities.includes(s));
}
export function enableConsole() {
enabled = [...severities];
}
export const $console = new Proxy(
{},
{
get: (_, prop) => {
if (prop in originalConsoles && enabled.includes(prop as TConsoleSeverity)) {
return (...args: any[]) => __tty(prop, args);
}
return () => null;
}
}
) as typeof console;
export async function withDisabledConsole<R>(
fn: () => Promise<R>,
sev?: TConsoleSeverity[]
): Promise<R> {
disableConsole(sev);
try {
const result = await fn();
enableConsole();
return result;
} catch (e) {
enableConsole();
throw e;
}
}
export function colorizeConsole(con: typeof console) {
for (const [key] of Object.entries(originalConsoles)) {
con[key] = $console[key];
}
}
+8 -1
View File
@@ -14,10 +14,17 @@ export class EventListener<E extends Event = Event> {
event: EventClass; event: EventClass;
handler: ListenerHandler<E>; handler: ListenerHandler<E>;
once: boolean = false; once: boolean = false;
id?: string;
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") { constructor(
event: EventClass,
handler: ListenerHandler<E>,
mode: ListenerMode = "async",
id?: string
) {
this.event = event; this.event = event;
this.handler = handler; this.handler = handler;
this.mode = mode; this.mode = mode;
this.id = id;
} }
} }
+13
View File
@@ -6,6 +6,7 @@ export type RegisterListenerConfig =
| { | {
mode?: ListenerMode; mode?: ListenerMode;
once?: boolean; once?: boolean;
id?: string;
}; };
export interface EmitsEvents { export interface EmitsEvents {
@@ -124,6 +125,15 @@ export class EventManager<
addListener(listener: EventListener) { addListener(listener: EventListener) {
this.throwIfEventNotRegistered(listener.event); this.throwIfEventNotRegistered(listener.event);
if (listener.id) {
const existing = this.listeners.find((l) => l.id === listener.id);
if (existing) {
// @todo: add a verbose option?
//console.warn(`Listener with id "${listener.id}" already exists.`);
return this;
}
}
this.listeners.push(listener); this.listeners.push(listener);
return this; return this;
} }
@@ -140,6 +150,9 @@ export class EventManager<
if (config.once) { if (config.once) {
listener.once = true; listener.once = true;
} }
if (config.id) {
listener.id = `${event.slug}-${config.id}`;
}
this.addListener(listener as any); this.addListener(listener as any);
} }
+2
View File
@@ -26,6 +26,8 @@ export {
} from "./object/query/query"; } from "./object/query/query";
export { Registry, type Constructor } from "./registry/Registry"; export { Registry, type Constructor } from "./registry/Registry";
export * from "./console";
// compatibility // compatibility
export type Middleware = MiddlewareHandler<any, any, any>; export type Middleware = MiddlewareHandler<any, any, any>;
export interface ClassController { export interface ClassController {
+93
View File
@@ -12,6 +12,20 @@ export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object"; return value !== null && typeof value === "object";
} }
export function omitKeys<T extends object, K extends keyof T>(
obj: T,
keys_: readonly K[]
): Omit<T, Extract<K, keyof T>> {
const keys = new Set(keys_);
const result = {} as Omit<T, Extract<K, keyof T>>;
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
if (!keys.has(key as K)) {
(result as any)[key] = value;
}
}
return result;
}
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T { export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
return Object.entries(obj).reduce((acc, [key, value]) => { return Object.entries(obj).reduce((acc, [key, value]) => {
try { try {
@@ -266,3 +280,82 @@ export function mergeObjectWith(object, source, customizer) {
return object; return object;
} }
export function isEqual(value1: any, value2: any): boolean {
// Each type corresponds to a particular comparison algorithm
const getType = (value: any) => {
if (value !== Object(value)) return "primitive";
if (Array.isArray(value)) return "array";
if (value instanceof Map) return "map";
if (value != null && [null, Object.prototype].includes(Object.getPrototypeOf(value)))
return "plainObject";
if (value instanceof Function) return "function";
throw new Error(
`deeply comparing an instance of type ${value1.constructor?.name} is not supported.`
);
};
const type = getType(value1);
if (type !== getType(value2)) {
return false;
}
if (type === "primitive") {
return value1 === value2 || (Number.isNaN(value1) && Number.isNaN(value2));
} else if (type === "array") {
return (
value1.length === value2.length &&
value1.every((iterValue: any, i: number) => isEqual(iterValue, value2[i]))
);
} else if (type === "map") {
// In this particular implementation, map keys are not
// being deeply compared, only map values.
return (
value1.size === value2.size &&
[...value1].every(([iterKey, iterValue]) => {
return value2.has(iterKey) && isEqual(iterValue, value2.get(iterKey));
})
);
} else if (type === "plainObject") {
const value1AsMap = new Map(Object.entries(value1));
const value2AsMap = new Map(Object.entries(value2));
return (
value1AsMap.size === value2AsMap.size &&
[...value1AsMap].every(([iterKey, iterValue]) => {
return value2AsMap.has(iterKey) && isEqual(iterValue, value2AsMap.get(iterKey));
})
);
} else if (type === "function") {
// just check signature
return value1.toString() === value2.toString();
} else {
throw new Error("Unreachable");
}
}
export function getPath(
object: object,
_path: string | (string | number)[],
defaultValue = undefined
): any {
const path = typeof _path === "string" ? _path.split(/[.\[\]\"]+/).filter((x) => x) : _path;
if (path.length === 0) {
return object;
}
try {
const [head, ...tail] = path;
if (!head || !(head in object)) {
return defaultValue;
}
return getPath(object[head], tail, defaultValue);
} catch (error) {
if (typeof defaultValue !== "undefined") {
return defaultValue;
}
throw new Error(`Invalid path: ${path.join(".")}`);
}
}
+260
View File
@@ -1,3 +1,7 @@
import { randomString } from "core/utils/strings";
import type { Context } from "hono";
import { extension, guess, isMimeType } from "media/storage/mime-types-tiny";
export function headersToObject(headers: Headers): Record<string, string> { export function headersToObject(headers: Headers): Record<string, string> {
if (!headers) return {}; if (!headers) return {};
return { ...Object.fromEntries(headers.entries()) }; return { ...Object.fromEntries(headers.entries()) };
@@ -82,3 +86,259 @@ export function decodeSearch(str) {
return out; return out;
} }
export function isReadableStream(value: unknown): value is ReadableStream {
return (
typeof value === "object" &&
value !== null &&
typeof (value as ReadableStream).getReader === "function"
);
}
export function isBlob(value: unknown): value is Blob {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Blob).arrayBuffer === "function" &&
typeof (value as Blob).type === "string"
);
}
export function isFile(value: unknown): value is File {
return (
isBlob(value) &&
typeof (value as File).name === "string" &&
typeof (value as File).lastModified === "number"
);
}
export function isArrayBuffer(value: unknown): value is ArrayBuffer {
return (
typeof value === "object" &&
value !== null &&
Object.prototype.toString.call(value) === "[object ArrayBuffer]"
);
}
export function isArrayBufferView(value: unknown): value is ArrayBufferView {
return typeof value === "object" && value !== null && ArrayBuffer.isView(value);
}
export function getContentName(request: Request): string | undefined;
export function getContentName(contentDisposition: string): string | undefined;
export function getContentName(headers: Headers): string | undefined;
export function getContentName(ctx: Headers | Request | string): string | undefined {
let c: string = "";
if (typeof ctx === "string") {
c = ctx;
} else if (ctx instanceof Headers) {
c = ctx.get("Content-Disposition") || "";
} else if (ctx instanceof Request) {
c = ctx.headers.get("Content-Disposition") || "";
}
const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/);
return match ? match[2] : undefined;
}
const FILE_SIGNATURES: Record<string, string> = {
"89504E47": "image/png",
FFD8FF: "image/jpeg",
"47494638": "image/gif",
"49492A00": "image/tiff", // Little Endian TIFF
"4D4D002A": "image/tiff", // Big Endian TIFF
"52494646????57454250": "image/webp", // WEBP (RIFF....WEBP)
"504B0304": "application/zip",
"25504446": "application/pdf",
"00000020667479706D70": "video/mp4",
"000001BA": "video/mpeg",
"000001B3": "video/mpeg",
"1A45DFA3": "video/webm",
"4F676753": "audio/ogg",
"494433": "audio/mpeg", // MP3 with ID3 header
FFF1: "audio/aac",
FFF9: "audio/aac",
"52494646????41564920": "audio/wav",
"52494646????57415645": "audio/wave",
"52494646????415550": "audio/aiff"
};
async function detectMimeType(
input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null
): Promise<string | undefined> {
if (!input) return;
let buffer: Uint8Array;
if (isReadableStream(input)) {
const reader = input.getReader();
const { value } = await reader.read();
if (!value) return;
buffer = new Uint8Array(value);
} else if (isBlob(input) || isFile(input)) {
buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer());
} else if (isArrayBuffer(input)) {
buffer = new Uint8Array(input);
} else if (isArrayBufferView(input)) {
buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
} else if (typeof input === "string") {
buffer = new TextEncoder().encode(input);
} else {
return;
}
const hex = Array.from(buffer.slice(0, 12))
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("");
for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) {
const regex = new RegExp("^" + signature.replace(/\?\?/g, ".."));
if (regex.test(hex)) return mime;
}
return;
}
export async function blobToFile(
blob: Blob | File | unknown,
overrides: FilePropertyBag & { name?: string } = {}
): Promise<File> {
if (isFile(blob)) return blob;
if (!isBlob(blob)) throw new Error("Not a Blob");
const type = isMimeType(overrides.type, ["application/octet-stream"])
? overrides.type
: await detectMimeType(blob);
const ext = type ? extension(type) : "";
const name = overrides.name || [randomString(16), ext].filter(Boolean).join(".");
return new File([blob], name, {
type: type || guess(name),
lastModified: Date.now()
});
}
export async function getFileFromContext(c: Context<any>): Promise<File> {
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
if (
contentType?.startsWith("multipart/form-data") ||
contentType?.startsWith("application/x-www-form-urlencoded")
) {
try {
const f = await c.req.formData();
if ([...f.values()].length > 0) {
const v = [...f.values()][0];
return await blobToFile(v);
}
} catch (e) {
console.warn("Error parsing form data", e);
}
} else {
try {
const blob = await c.req.blob();
if (isFile(blob)) {
return blob;
} else if (isBlob(blob)) {
return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType });
}
} catch (e) {
console.warn("Error parsing blob", e);
}
}
throw new Error("No file found in request");
}
export async function getBodyFromContext(c: Context<any>): Promise<ReadableStream | File> {
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
if (
!contentType?.startsWith("multipart/form-data") &&
!contentType?.startsWith("application/x-www-form-urlencoded")
) {
const body = c.req.raw.body;
if (body) {
return body;
}
}
return getFileFromContext(c);
}
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
// biome-ignore lint/suspicious/noConstEnum: <explanation>
export const enum HttpStatus {
// Informational responses (100199)
CONTINUE = 100,
SWITCHING_PROTOCOLS = 101,
PROCESSING = 102,
EARLY_HINTS = 103,
// Successful responses (200299)
OK = 200,
CREATED = 201,
ACCEPTED = 202,
NON_AUTHORITATIVE_INFORMATION = 203,
NO_CONTENT = 204,
RESET_CONTENT = 205,
PARTIAL_CONTENT = 206,
MULTI_STATUS = 207,
ALREADY_REPORTED = 208,
IM_USED = 226,
// Redirection messages (300399)
MULTIPLE_CHOICES = 300,
MOVED_PERMANENTLY = 301,
FOUND = 302,
SEE_OTHER = 303,
NOT_MODIFIED = 304,
USE_PROXY = 305,
TEMPORARY_REDIRECT = 307,
PERMANENT_REDIRECT = 308,
// Client error responses (400499)
BAD_REQUEST = 400,
UNAUTHORIZED = 401,
PAYMENT_REQUIRED = 402,
FORBIDDEN = 403,
NOT_FOUND = 404,
METHOD_NOT_ALLOWED = 405,
NOT_ACCEPTABLE = 406,
PROXY_AUTHENTICATION_REQUIRED = 407,
REQUEST_TIMEOUT = 408,
CONFLICT = 409,
GONE = 410,
LENGTH_REQUIRED = 411,
PRECONDITION_FAILED = 412,
PAYLOAD_TOO_LARGE = 413,
URI_TOO_LONG = 414,
UNSUPPORTED_MEDIA_TYPE = 415,
RANGE_NOT_SATISFIABLE = 416,
EXPECTATION_FAILED = 417,
IM_A_TEAPOT = 418,
MISDIRECTED_REQUEST = 421,
UNPROCESSABLE_ENTITY = 422,
LOCKED = 423,
FAILED_DEPENDENCY = 424,
TOO_EARLY = 425,
UPGRADE_REQUIRED = 426,
PRECONDITION_REQUIRED = 428,
TOO_MANY_REQUESTS = 429,
REQUEST_HEADER_FIELDS_TOO_LARGE = 431,
UNAVAILABLE_FOR_LEGAL_REASONS = 451,
// Server error responses (500599)
INTERNAL_SERVER_ERROR = 500,
NOT_IMPLEMENTED = 501,
BAD_GATEWAY = 502,
SERVICE_UNAVAILABLE = 503,
GATEWAY_TIMEOUT = 504,
HTTP_VERSION_NOT_SUPPORTED = 505,
VARIANT_ALSO_NEGOTIATES = 506,
INSUFFICIENT_STORAGE = 507,
LOOP_DETECTED = 508,
NOT_EXTENDED = 510,
NETWORK_AUTHENTICATION_REQUIRED = 511
}
+4 -1
View File
@@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) {
case "SCREAMING_SNAKE_CASE": case "SCREAMING_SNAKE_CASE":
return snakeToPascalWithSpaces(str.toLowerCase()); return snakeToPascalWithSpaces(str.toLowerCase());
case "unknown": case "unknown":
return str; return ucFirst(str);
} }
} }
export function autoFormatString(str: string) {
return identifierToHumanReadable(str);
}
export function kebabToPascalWithSpaces(str: string): string { export function kebabToPascalWithSpaces(str: string): string {
return str.split("-").map(ucFirst).join(" "); return str.split("-").map(ucFirst).join(" ");
+18
View File
@@ -42,3 +42,21 @@ export function enableConsoleLog() {
console[severity as ConsoleSeverity] = fn; console[severity as ConsoleSeverity] = fn;
}); });
} }
export function tryit(fn: () => void, fallback?: any) {
try {
return fn();
} catch (e) {
return fallback || e;
}
}
export function formatMemoryUsage() {
const usage = process.memoryUsage();
return {
rss: usage.rss / 1024 / 1024,
heapUsed: usage.heapUsed / 1024 / 1024,
external: usage.external / 1024 / 1024,
arrayBuffers: usage.arrayBuffers / 1024 / 1024
};
}
+1 -2
View File
@@ -21,7 +21,6 @@ import {
type ValueErrorIterator type ValueErrorIterator
} from "@sinclair/typebox/errors"; } from "@sinclair/typebox/errors";
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value"; import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
import { cloneDeep } from "lodash-es";
export type RecursivePartial<T> = { export type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[] [P in keyof T]?: T[P] extends (infer U)[]
@@ -73,7 +72,7 @@ export class TypeInvalidError extends Error {
} }
export function stripMark<O = any>(obj: O) { export function stripMark<O = any>(obj: O) {
const newObj = cloneDeep(obj); const newObj = structuredClone(obj);
mark(newObj, false); mark(newObj, false);
return newObj as O; return newObj as O;
} }
+14 -11
View File
@@ -1,10 +1,10 @@
import type { DB } from "core"; import type { DB } from "core";
import type { EntityData, RepoQuery, RepoQueryIn, RepositoryResponse } from "data"; import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules"; import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
export type DataApiOptions = BaseModuleApiOptions & { export type DataApiOptions = BaseModuleApiOptions & {
queryLengthLimit: number; queryLengthLimit: number;
defaultQuery: Partial<RepoQuery>; defaultQuery: Partial<RepoQueryIn>;
}; };
export class DataApi extends ModuleApi<DataApiOptions> { export class DataApi extends ModuleApi<DataApiOptions> {
@@ -23,7 +23,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
id: PrimaryFieldType, id: PrimaryFieldType,
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {} query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
) { ) {
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query); return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
["entity", entity as any, id],
query
);
} }
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi<DataApiOptions> {
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">; type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
const input = query ?? this.options.defaultQuery; const input = query ?? this.options.defaultQuery;
const req = this.get<T>([entity as any], input); const req = this.get<T>(["entity", entity as any], input);
if (req.request.url.length <= this.options.queryLengthLimit) { if (req.request.url.length <= this.options.queryLengthLimit) {
return req; return req;
} }
return this.post<T>([entity as any, "query"], input); return this.post<T>(["entity", entity as any, "query"], input);
} }
readManyByReference< readManyByReference<
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
Data = R extends keyof DB ? DB[R] : EntityData Data = R extends keyof DB ? DB[R] : EntityData
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) { >(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>( return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
[entity as any, id, reference], ["entity", entity as any, id, reference],
query ?? this.options.defaultQuery query ?? this.options.defaultQuery
); );
} }
@@ -57,7 +60,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
entity: E, entity: E,
input: Omit<Data, "id"> input: Omit<Data, "id">
) { ) {
return this.post<RepositoryResponse<Data>>([entity as any], input); return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
} }
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
id: PrimaryFieldType, id: PrimaryFieldType,
input: Partial<Omit<Data, "id">> input: Partial<Omit<Data, "id">>
) { ) {
return this.patch<RepositoryResponse<Data>>([entity as any, id], input); return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
} }
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>( deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
entity: E, entity: E,
id: PrimaryFieldType id: PrimaryFieldType
) { ) {
return this.delete<RepositoryResponse<Data>>([entity as any, id]); return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
} }
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) { count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
return this.post<RepositoryResponse<{ entity: E; count: number }>>( return this.post<RepositoryResponse<{ entity: E; count: number }>>(
[entity as any, "fn", "count"], ["entity", entity as any, "fn", "count"],
where where
); );
} }
+88 -46
View File
@@ -109,44 +109,7 @@ export class DataController extends Controller {
}); });
/** /**
* Function endpoints * Schema endpoints
*/
hono
// fn: count
.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.json() as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
}
)
// fn: exists
.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.json() as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists });
}
);
/**
* Read endpoints
*/ */
hono hono
// read entity schema // read entity schema
@@ -181,7 +144,7 @@ export class DataController extends Controller {
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity, context } = c.req.param(); const { entity, context } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.warn("not found:", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const _entity = this.em.entity(entity); const _entity = this.em.entity(entity);
@@ -197,7 +160,92 @@ export class DataController extends Controller {
...schema ...schema
}); });
} }
);
// entity endpoints
hono.route("/entity", this.getEntityRoutes());
/**
* Info endpoints
*/
hono.get("/info/:entity", async (c) => {
const { entity } = c.req.param();
if (!this.entityExists(entity)) {
return c.notFound();
}
const _entity = this.em.entity(entity);
const fields = _entity.fields.map((f) => f.name);
const $rels = (r: any) =>
r.map((r: any) => ({
entity: r.other(_entity).entity.name,
ref: r.other(_entity).reference
}));
return c.json({
name: _entity.name,
fields,
relations: {
all: $rels(this.em.relations.relationsOf(_entity)),
listable: $rels(this.em.relations.listableRelationsOf(_entity)),
source: $rels(this.em.relations.sourceRelationsOf(_entity)),
target: $rels(this.em.relations.targetRelationsOf(_entity))
}
});
});
return hono.all("*", (c) => c.notFound());
}
private getEntityRoutes() {
const { permission } = this.middlewares;
const hono = this.create();
const definedEntities = this.em.entities.map((e) => e.name);
const tbNumber = Type.Transform(Type.String({ pattern: "^[1-9][0-9]{0,}$" }))
.Decode(Number.parseInt)
.Encode(String);
/**
* Function endpoints
*/
hono
// fn: count
.post(
"/:entity/fn/count",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = (await c.req.json()) as any;
const result = await this.em.repository(entity).count(where);
return c.json({ entity, count: result.count });
}
) )
// fn: exists
.post(
"/:entity/fn/exists",
permission(DataPermissions.entityRead),
tb("param", Type.Object({ entity: Type.String() })),
async (c) => {
const { entity } = c.req.valid("param");
if (!this.entityExists(entity)) {
return c.notFound();
}
const where = c.req.json() as any;
const result = await this.em.repository(entity).exists(where);
return c.json({ entity, exists: result.exists });
}
);
/**
* Read endpoints
*/
hono
// read many // read many
.get( .get(
"/:entity", "/:entity",
@@ -208,7 +256,7 @@ export class DataController extends Controller {
//console.log("request", c.req.raw); //console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
console.log("not found", entity, definedEntities); console.warn("not found:", entity, definedEntities);
return c.notFound(); return c.notFound();
} }
const options = c.req.valid("query") as RepoQuery; const options = c.req.valid("query") as RepoQuery;
@@ -327,12 +375,9 @@ export class DataController extends Controller {
// delete one // delete one
.delete( .delete(
"/:entity/:id", "/:entity/:id",
permission(DataPermissions.entityDelete), permission(DataPermissions.entityDelete),
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })), tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
async (c) => { async (c) => {
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
const { entity, id } = c.req.param(); const { entity, id } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
@@ -350,14 +395,11 @@ export class DataController extends Controller {
tb("param", Type.Object({ entity: Type.String() })), tb("param", Type.Object({ entity: Type.String() })),
tb("json", querySchema.properties.where), tb("json", querySchema.properties.where),
async (c) => { async (c) => {
//console.log("request", c.req.raw);
const { entity } = c.req.param(); const { entity } = c.req.param();
if (!this.entityExists(entity)) { if (!this.entityExists(entity)) {
return c.notFound(); return c.notFound();
} }
const where = c.req.valid("json") as RepoQuery["where"]; const where = c.req.valid("json") as RepoQuery["where"];
//console.log("where", where);
const result = await this.em.mutator(entity).deleteWhere(where); const result = await this.em.mutator(entity).deleteWhere(where);
return c.json(this.mutatorResult(result)); return c.json(this.mutatorResult(result));
+2 -8
View File
@@ -1,6 +1,6 @@
import { type Client, type Config, type InStatement, createClient } from "@libsql/client"; import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
import { LibsqlDialect } from "@libsql/kysely-libsql"; import { LibsqlDialect } from "@libsql/kysely-libsql";
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely"; import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin"; import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner"; import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
import type { QB } from "./Connection"; import type { QB } from "./Connection";
@@ -28,7 +28,7 @@ export class LibsqlConnection extends SqliteConnection {
constructor(clientOrCredentials: Client | LibSqlCredentials) { constructor(clientOrCredentials: Client | LibSqlCredentials) {
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()]; const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
let client: Client; let client: Client;
if ("url" in clientOrCredentials) { if (clientOrCredentials && "url" in clientOrCredentials) {
let { url, authToken, protocol } = clientOrCredentials; let { url, authToken, protocol } = clientOrCredentials;
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) { if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
console.log("changing protocol to", protocol); console.log("changing protocol to", protocol);
@@ -36,11 +36,8 @@ export class LibsqlConnection extends SqliteConnection {
url = `${protocol}://${rest}`; url = `${protocol}://${rest}`;
} }
//console.log("using", url, { protocol });
client = createClient({ url, authToken }); client = createClient({ url, authToken });
} else { } else {
//console.log("-- client provided");
client = clientOrCredentials; client = clientOrCredentials;
} }
@@ -48,7 +45,6 @@ export class LibsqlConnection extends SqliteConnection {
// @ts-expect-error libsql has type issues // @ts-expect-error libsql has type issues
dialect: new CustomLibsqlDialect({ client }), dialect: new CustomLibsqlDialect({ client }),
plugins plugins
//log: ["query"],
}); });
super(kysely, {}, plugins); super(kysely, {}, plugins);
@@ -74,7 +70,6 @@ export class LibsqlConnection extends SqliteConnection {
}> { }> {
const stms: InStatement[] = queries.map((q) => { const stms: InStatement[] = queries.map((q) => {
const compiled = q.compile(); const compiled = q.compile();
//console.log("compiled", compiled.sql, compiled.parameters);
return { return {
sql: compiled.sql, sql: compiled.sql,
args: compiled.parameters as any[] args: compiled.parameters as any[]
@@ -91,7 +86,6 @@ export class LibsqlConnection extends SqliteConnection {
const rows = await kyselyPlugins.transformResultRows(r.rows); const rows = await kyselyPlugins.transformResultRows(r.rows);
data.push(rows); data.push(rows);
} }
//console.log("data", data);
return data; return data;
} }
+10 -10
View File
@@ -5,7 +5,7 @@ import type {
ExpressionBuilder, ExpressionBuilder,
Kysely, Kysely,
SchemaMetadata, SchemaMetadata,
TableMetadata, TableMetadata
} from "kysely"; } from "kysely";
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely"; import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
import type { ConnectionIntrospector, IndexMetadata } from "./Connection"; import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
@@ -62,7 +62,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
seqno: number; seqno: number;
cid: number; cid: number;
name: string; name: string;
}>`pragma_index_info(${index})`.as("index_info"), }>`pragma_index_info(${index})`.as("index_info")
) )
.select(["seqno", "cid", "name"]) .select(["seqno", "cid", "name"])
.orderBy("cid") .orderBy("cid")
@@ -74,8 +74,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
isUnique: isUnique, isUnique: isUnique,
columns: columns.map((col) => ({ columns: columns.map((col) => ({
name: col.name, name: col.name,
order: col.seqno, order: col.seqno
})), }))
}; };
} }
@@ -87,7 +87,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
} }
async getTables( async getTables(
options: DatabaseMetadataOptions = { withInternalKyselyTables: false }, options: DatabaseMetadataOptions = { withInternalKyselyTables: false }
): Promise<TableMetadata[]> { ): Promise<TableMetadata[]> {
let query = this.#db let query = this.#db
.selectFrom("sqlite_master") .selectFrom("sqlite_master")
@@ -99,7 +99,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
if (!options.withInternalKyselyTables) { if (!options.withInternalKyselyTables) {
query = query.where( query = query.where(
this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]), this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE])
); );
} }
if (this._excludeTables.length > 0) { if (this._excludeTables.length > 0) {
@@ -112,7 +112,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> { async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
return { return {
tables: await this.getTables(options), tables: await this.getTables(options)
}; };
} }
@@ -142,7 +142,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
type: string; type: string;
notnull: 0 | 1; notnull: 0 | 1;
dflt_value: any; dflt_value: any;
}>`pragma_table_info(${table})`.as("table_info"), }>`pragma_table_info(${table})`.as("table_info")
) )
.select(["name", "type", "notnull", "dflt_value"]) .select(["name", "type", "notnull", "dflt_value"])
.orderBy("cid") .orderBy("cid")
@@ -157,8 +157,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
isNullable: !col.notnull, isNullable: !col.notnull,
isAutoIncrementing: col.name === autoIncrementCol, isAutoIncrementing: col.name === autoIncrementCol,
hasDefaultValue: col.dflt_value != null, hasDefaultValue: col.dflt_value != null,
comment: undefined, comment: undefined
})), }))
}; };
} }
} }
+7
View File
@@ -165,6 +165,13 @@ export class Entity<
return this.getField(name); return this.getField(name);
} }
hasField(name: string): boolean;
hasField(field: Field): boolean;
hasField(nameOrField: string | Field): boolean {
const name = typeof nameOrField === "string" ? nameOrField : nameOrField.name;
return this.fields.findIndex((field) => field.name === name) !== -1;
}
getFields(include_virtual: boolean = false): Field[] { getFields(include_virtual: boolean = false): Field[] {
if (include_virtual) return this.fields; if (include_virtual) return this.fields;
return this.fields.filter((f) => !f.isVirtual()); return this.fields.filter((f) => !f.isVirtual());
+16 -10
View File
@@ -107,18 +107,24 @@ export class Mutator<
protected async many(qb: MutatorQB): Promise<MutatorResponse> { protected async many(qb: MutatorQB): Promise<MutatorResponse> {
const entity = this.entity; const entity = this.entity;
const { sql, parameters } = qb.compile(); const { sql, parameters } = qb.compile();
//console.log("mutatoar:exec", sql, parameters);
const result = await qb.execute();
const data = this.em.hydrate(entity.name, result) as EntityData[]; try {
const result = await qb.execute();
return { const data = this.em.hydrate(entity.name, result) as EntityData[];
entity,
sql, return {
parameters: [...parameters], entity,
result: result, sql,
data parameters: [...parameters],
}; result: result,
data
};
} catch (e) {
// @todo: redact
console.log("[Error in query]", sql);
throw e;
}
} }
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> { protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> {
+10 -10
View File
@@ -1,4 +1,5 @@
import type { DB as DefaultDB, PrimaryFieldType } from "core"; import type { DB as DefaultDB, PrimaryFieldType } from "core";
import { $console } from "core";
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import { type SelectQueryBuilder, sql } from "kysely"; import { type SelectQueryBuilder, sql } from "kysely";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
@@ -73,7 +74,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
sort: entity.getDefaultSort(), sort: entity.getDefaultSort(),
select: entity.getSelect() select: entity.getSelect()
}; };
//console.log("validated", validated);
if (!options) return validated; if (!options) return validated;
@@ -144,7 +144,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}); });
if (invalid.length > 0) { if (invalid.length > 0) {
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`); throw new InvalidSearchParamsException(
`Invalid where field(s): ${invalid.join(", ")}`
).context({ aliases, entity: entity.name });
} }
validated.where = options.where; validated.where = options.where;
@@ -160,7 +162,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> { protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
const entity = this.entity; const entity = this.entity;
const compiled = qb.compile(); const compiled = qb.compile();
//console.log("performQuery", compiled.sql, compiled.parameters); //$console.log("performQuery", compiled.sql, compiled.parameters);
const start = performance.now(); const start = performance.now();
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as); const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
@@ -179,7 +181,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
totalQuery, totalQuery,
qb qb
]); ]);
//console.log("result", { _count, _total }); //$console.log("result", { _count, _total });
const time = Number.parseFloat((performance.now() - start).toFixed(2)); const time = Number.parseFloat((performance.now() - start).toFixed(2));
const data = this.em.hydrate(entity.name, result); const data = this.em.hydrate(entity.name, result);
@@ -200,7 +202,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
}; };
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) {
console.error("[ERROR] Repository.performQuery", e.message); $console.error("[ERROR] Repository.performQuery", e.message);
} }
throw e; throw e;
@@ -253,7 +255,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
...config?.defaults ...config?.defaults
}; };
/*console.log("build query options", { /*$console.log("build query options", {
entity: entity.name, entity: entity.name,
options, options,
config config
@@ -334,7 +336,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> { async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
const { qb, options } = this.buildQuery(_options); const { qb, options } = this.buildQuery(_options);
//console.log("findMany:options", options);
await this.emgr.emit( await this.emgr.emit(
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options }) new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
@@ -386,7 +387,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
} }
}; };
//console.log("findManyOptions", newEntity.name, findManyOptions);
return this.cloneFor(newEntity).findMany(findManyOptions); return this.cloneFor(newEntity).findMany(findManyOptions);
} }
@@ -427,9 +427,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
qb = qb.limit(1); qb = qb.limit(1);
const compiled = qb.compile(); const compiled = qb.compile();
//console.log("exists query", compiled.sql, compiled.parameters); //$console.log("exists query", compiled.sql, compiled.parameters);
const result = await qb.execute(); const result = await qb.execute();
//console.log("result", result); //$console.log("result", result);
return { return {
sql: compiled.sql, sql: compiled.sql,
+1 -1
View File
@@ -37,7 +37,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
} }
override async transformPersist(value: any): Promise<number> { override async transformPersist(value: any): Promise<number> {
throw new Error("This function should not be called"); throw new Error("PrimaryField: This function should not be called");
} }
override toJsonSchema() { override toJsonSchema() {
+4
View File
@@ -18,6 +18,8 @@ export { Connection } from "./connection/Connection";
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection"; export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
export { SqliteConnection } from "./connection/SqliteConnection"; export { SqliteConnection } from "./connection/SqliteConnection";
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection"; export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
export { SqliteIntrospector } from "./connection/SqliteIntrospector";
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
export { constructEntity, constructRelation } from "./schema/constructor"; export { constructEntity, constructRelation } from "./schema/constructor";
@@ -28,3 +30,5 @@ export const DatabaseEvents = {
export { MutatorEvents, RepositoryEvents }; export { MutatorEvents, RepositoryEvents };
export * as DataPermissions from "./permissions"; export * as DataPermissions from "./permissions";
export { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
+17 -37
View File
@@ -105,13 +105,13 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
} }
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> { override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
const conn = this.connectionEntity; const { other, otherRef } = this.getQueryInfo(entity);
return { return {
where: { where: {
[`${conn.name}.${entity.name}_${entity.getPrimaryField().name}`]: id [otherRef]: id
}, },
join: [this.target.reference] join: [other.reference]
}; };
} }
@@ -160,47 +160,27 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
.whereRef(entityRef, "=", otherRef) .whereRef(entityRef, "=", otherRef)
.innerJoin(...join) .innerJoin(...join)
.limit(limit); .limit(limit);
/*return qb.select((eb) => {
const select: any[] = other.entity.getSelect(other.entity.name);
// @todo: also add to find by references
if (additionalFields.length > 0) {
const conn = this.connectionEntity.name;
select.push(
jsonBuildObject(
Object.fromEntries(
additionalFields.map((f) => [f.name, eb.ref(`${conn}.${f.name}`)])
)
).as(this.connectionTableMappedName)
);
}
return jsonFrom(
eb
.selectFrom(other.entity.name)
.select(select)
.whereRef(entityRef, "=", otherRef)
.innerJoin(...join)
.limit(limit)
).as(other.reference);
});*/
} }
initialize(em: EntityManager<any>) { initialize(em: EntityManager<any>) {
this.em = em; this.em = em;
//this.connectionEntity.addField(new RelationField(this.source.entity)); const sourceField = RelationField.create(this, this.source);
//this.connectionEntity.addField(new RelationField(this.target.entity)); const targetField = RelationField.create(this, this.target);
this.connectionEntity.addField(RelationField.create(this, this.source));
this.connectionEntity.addField(RelationField.create(this, this.target));
// @todo: check this if (em.hasEntity(this.connectionEntity)) {
for (const field of this.additionalFields) { // @todo: also check for correct signatures of field
this.source.entity.addField(new VirtualField(this.connectionTableMappedName)); if (!this.connectionEntity.hasField(sourceField)) {
this.target.entity.addField(new VirtualField(this.connectionTableMappedName)); this.connectionEntity.addField(sourceField);
}
if (!this.connectionEntity.hasField(targetField)) {
this.connectionEntity.addField(targetField);
}
} else {
this.connectionEntity.addField(sourceField);
this.connectionEntity.addField(targetField);
em.addEntity(this.connectionEntity);
} }
em.addEntity(this.connectionEntity);
} }
override getName(): string { override getName(): string {
+1 -1
View File
@@ -88,7 +88,7 @@ export class RelationField extends Field<RelationFieldConfig> {
} }
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> { override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
throw new Error("This function should not be called"); throw new Error("RelationField: This function should not be called");
} }
override toJsonSchema() { override toJsonSchema() {
+16
View File
@@ -2,6 +2,7 @@ import type { PrimaryFieldType } from "core";
import type { Entity, EntityManager } from "../entities"; import type { Entity, EntityManager } from "../entities";
import { import {
type EntityRelation, type EntityRelation,
ManyToManyRelation,
type MutationOperation, type MutationOperation,
MutationOperations, MutationOperations,
RelationField RelationField
@@ -26,11 +27,26 @@ export class RelationMutator {
*/ */
getRelationalKeys(): string[] { getRelationalKeys(): string[] {
const references: string[] = []; const references: string[] = [];
// if persisting a manytomany connection table
// @todo: improve later
if (this.entity.type === "generated") {
const relation = this.em.relations.all.find(
(r) => r instanceof ManyToManyRelation && r.connectionEntity.name === this.entity.name
);
if (relation instanceof ManyToManyRelation) {
references.push(
...this.entity.fields.filter((f) => f.type === "relation").map((f) => f.name)
);
}
}
this.em.relationsOf(this.entity.name).map((r) => { this.em.relationsOf(this.entity.name).map((r) => {
const info = r.helper(this.entity.name).getMutationInfo(); const info = r.helper(this.entity.name).getMutationInfo();
references.push(info.reference); references.push(info.reference);
info.local_field && references.push(info.local_field); info.local_field && references.push(info.local_field);
}); });
return references; return references;
} }
+44 -14
View File
@@ -5,7 +5,8 @@ import {
type StaticDecode, type StaticDecode,
StringEnum, StringEnum,
Type, Type,
Value Value,
isObject
} from "core/utils"; } from "core/utils";
import { WhereBuilder, type WhereQuery } from "../entities"; import { WhereBuilder, type WhereQuery } from "../entities";
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
>; >;
export const withSchema = <TSelf extends TThis>(Self: TSelf) => export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)])) Type.Transform(
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)])
)
.Decode((value) => { .Decode((value) => {
let _value = typeof value === "string" ? [value] : value; // images
// images,comments
// ["images","comments"]
// { "images": {} }
if (Array.isArray(value)) { if (!Array.isArray(value) && isObject(value)) {
if (!value.every((v) => typeof v === "string")) { console.log("is object");
throw new Error("Invalid 'with' schema"); return value as RepoWithSchema;
}
_value = value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
} }
return _value as RepoWithSchema; let _value: any = null;
if (typeof value === "string") {
// if stringified object
if (value.match(/^\{/)) {
return JSON.parse(value) as RepoWithSchema;
}
// if stringified array
if (value.match(/^\[/)) {
_value = JSON.parse(value) as string[];
// if comma-separated string
} else if (value.includes(",")) {
_value = value.split(",");
// if single string
} else {
_value = [value];
}
} else if (Array.isArray(value)) {
_value = value;
}
if (!_value || !Array.isArray(_value) || !_value.every((v) => typeof v === "string")) {
throw new Error("Invalid 'with' schema");
}
return _value.reduce((acc, v) => {
acc[v] = {};
return acc;
}, {} as RepoWithSchema);
}) })
.Encode((value) => value); .Encode((value) => value);
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
offset?: number; offset?: number;
sort?: string | { by: string; dir: "asc" | "desc" }; sort?: string | { by: string; dir: "asc" | "desc" };
select?: string[]; select?: string[];
with?: string[] | Record<string, RepoQueryIn>; with?: string | string[] | Record<string, RepoQueryIn>;
join?: string[]; join?: string[];
where?: WhereQuery; where?: WhereQuery;
}; };
+2
View File
@@ -63,6 +63,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
}); });
}); });
hono.all("*", (c) => c.notFound());
this.ctx.server.route(this.config.basepath, hono); this.ctx.server.route(this.config.basepath, hono);
// register flows // register flows
+10 -5
View File
@@ -1,4 +1,11 @@
export { App, createApp, AppEvents, type AppConfig, type CreateAppConfig } from "./App"; export {
App,
createApp,
AppEvents,
type AppConfig,
type CreateAppConfig,
type AppPlugin
} from "./App";
export { export {
getDefaultConfig, getDefaultConfig,
@@ -6,14 +13,12 @@ export {
type ModuleConfigs, type ModuleConfigs,
type ModuleSchemas, type ModuleSchemas,
type ModuleManagerOptions, type ModuleManagerOptions,
type ModuleBuildContext type ModuleBuildContext,
type InitialModuleConfigs
} from "./modules/ModuleManager"; } from "./modules/ModuleManager";
export * as middlewares from "modules/middlewares"; export * as middlewares from "modules/middlewares";
export { registries } from "modules/registries"; export { registries } from "modules/registries";
export type * from "./adapter";
export { Api, type ApiOptions } from "./Api";
export type { MediaFieldSchema } from "media/AppMedia"; export type { MediaFieldSchema } from "media/AppMedia";
export type { UserFieldSchema } from "auth/AppAuth"; export type { UserFieldSchema } from "auth/AppAuth";
+7 -7
View File
@@ -40,7 +40,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
let adapter: StorageAdapter; let adapter: StorageAdapter;
try { try {
const { type, config } = this.config.adapter; const { type, config } = this.config.adapter;
adapter = new (registry.get(type as any).cls)(config as any); const cls = registry.get(type as any).cls;
adapter = new cls(config as any);
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr); this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
this.setBuilt(); this.setBuilt();
@@ -53,8 +54,6 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
index(media).on(["path"], true).on(["reference"]); index(media).on(["path"], true).on(["reference"]);
}) })
); );
this.setBuilt();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
throw new Error( throw new Error(
@@ -124,11 +123,12 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
async (e) => { async (e) => {
const mutator = em.mutator(media); const mutator = em.mutator(media);
mutator.__unstable_toggleSystemEntityCreation(false); mutator.__unstable_toggleSystemEntityCreation(false);
await mutator.insertOne(this.uploadedEventDataToMediaPayload(e.params)); const payload = this.uploadedEventDataToMediaPayload(e.params);
const { data } = await mutator.insertOne(payload);
mutator.__unstable_toggleSystemEntityCreation(true); mutator.__unstable_toggleSystemEntityCreation(true);
console.log("App:storage:file uploaded", e); return { data };
}, },
"sync" { mode: "sync", id: "add-data-media" }
); );
// when file is deleted, sync with media entity // when file is deleted, sync with media entity
@@ -144,7 +144,7 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
console.log("App:storage:file deleted", e); console.log("App:storage:file deleted", e);
}, },
"sync" { mode: "sync", id: "delete-data-media" }
); );
} }
+119 -9
View File
@@ -1,4 +1,10 @@
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi"; import type { FileListObject } from "media";
import {
type BaseModuleApiOptions,
ModuleApi,
type PrimaryFieldType,
type TInput
} from "modules/ModuleApi";
import type { FileWithPath } from "ui/elements/media/file-selector"; import type { FileWithPath } from "ui/elements/media/file-selector";
export type MediaApiOptions = BaseModuleApiOptions & {}; export type MediaApiOptions = BaseModuleApiOptions & {};
@@ -10,15 +16,36 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
}; };
} }
getFiles() { listFiles() {
return this.get(["files"]); return this.get<FileListObject[]>(["files"]);
} }
getFile(filename: string) { getFile(filename: string) {
return this.get(["file", filename]); return this.get<ReadableStream<Uint8Array>>(["file", filename], undefined, {
headers: {
Accept: "*/*"
}
});
} }
getFileUploadUrl(file: FileWithPath): string { async getFileStream(filename: string): Promise<ReadableStream<Uint8Array>> {
const { res } = await this.getFile(filename);
if (!res.ok || !res.body) {
throw new Error("Failed to fetch file");
}
return res.body;
}
async download(filename: string): Promise<File> {
const { res } = await this.getFile(filename);
if (!res.ok || !res.body) {
throw new Error("Failed to fetch file");
}
return (await res.blob()) as File;
}
getFileUploadUrl(file?: FileWithPath): string {
if (!file) return this.getUrl("/upload");
return this.getUrl(`/upload/${file.path}`); return this.getUrl(`/upload/${file.path}`);
} }
@@ -32,10 +59,93 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
}); });
} }
uploadFile(file: File) { protected uploadFile(
const formData = new FormData(); body: File | ReadableStream,
formData.append("file", file); opts?: {
return this.post(["upload"], formData); filename?: string;
path?: TInput;
_init?: Omit<RequestInit, "body">;
}
) {
const headers = {
"Content-Type": "application/octet-stream",
...(opts?._init?.headers || {})
};
let name: string = opts?.filename || "";
try {
if (typeof (body as File).type !== "undefined") {
headers["Content-Type"] = (body as File).type;
}
if (!opts?.filename) {
name = (body as File).name;
}
} catch (e) {}
if (name && name.length > 0 && name.includes("/")) {
name = name.split("/").pop() || "";
}
const init = {
...(opts?._init || {}),
headers
};
if (opts?.path) {
return this.post(opts.path, body, init);
}
if (!name || name.length === 0) {
throw new Error("Invalid filename");
}
return this.post(opts?.path ?? ["upload", name], body, init);
}
async upload(
item: Request | Response | string | File | ReadableStream,
opts: {
filename?: string;
_init?: Omit<RequestInit, "body">;
path?: TInput;
} = {}
) {
if (item instanceof Request || typeof item === "string") {
const res = await this.fetcher(item);
if (!res.ok || !res.body) {
throw new Error("Failed to fetch file");
}
return this.uploadFile(res.body, opts);
} else if (item instanceof Response) {
if (!item.body) {
throw new Error("Invalid response");
}
return this.uploadFile(item.body, {
...(opts ?? {}),
_init: {
...(opts._init ?? {}),
headers: {
...(opts._init?.headers ?? {}),
"Content-Type": item.headers.get("Content-Type") || "application/octet-stream"
}
}
});
}
return this.uploadFile(item, opts);
}
async uploadToEntity(
entity: string,
id: PrimaryFieldType,
field: string,
item: Request | Response | string | File | ReadableStream,
opts?: {
_init?: Omit<RequestInit, "body">;
}
) {
return this.upload(item, {
...opts,
path: ["entity", entity, id, field]
});
} }
deleteFile(filename: string) { deleteFile(filename: string) {
+53 -29
View File
@@ -1,6 +1,5 @@
import { tbValidator as tb } from "core"; import { isDebug, tbValidator as tb } from "core";
import { Type } from "core/utils"; import { HttpStatus, Type, getFileFromContext } from "core/utils";
import { bodyLimit } from "hono/body-limit";
import type { StorageAdapter } from "media"; import type { StorageAdapter } from "media";
import { StorageEvents, getRandomizedFilename } from "media"; import { StorageEvents, getRandomizedFilename } from "media";
import { Controller } from "modules/Controller"; import { Controller } from "modules/Controller";
@@ -42,7 +41,6 @@ export class MediaController extends Controller {
if (!filename) { if (!filename) {
throw new Error("No file name provided"); throw new Error("No file name provided");
} }
//console.log("getting file", filename, headersToObject(c.req.raw.headers));
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename })); await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
return await this.getStorageAdapter().getObject(filename, c.req.raw.headers); return await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
@@ -59,24 +57,39 @@ export class MediaController extends Controller {
return c.json({ message: "File deleted" }); return c.json({ message: "File deleted" });
}); });
const uploadSizeMiddleware = bodyLimit({ const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
maxSize: this.getStorage().getConfig().body_max_size,
onError: (c: any) => { if (isDebug()) {
return c.text(`Payload exceeds ${this.getStorage().getConfig().body_max_size}`, 413); hono.post("/inspect", async (c) => {
} const file = await getFileFromContext(c);
}); return c.json({
type: file?.type,
name: file?.name,
size: file?.size
});
});
}
// upload file // upload file
// @todo: add required type for "upload endpoints" // @todo: add required type for "upload endpoints"
hono.post("/upload/:filename", uploadSizeMiddleware, async (c) => { hono.post("/upload/:filename?", async (c) => {
const { filename } = c.req.param(); const reqname = c.req.param("filename");
if (!filename) {
throw new Error("No file name provided"); const body = await getFileFromContext(c);
if (!body) {
return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
}
if (body.size > maxSize) {
return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE
);
} }
const file = await this.getStorage().getFileFromRequest(c); const filename = reqname ?? getRandomizedFilename(body as File);
console.log("----file", file); const res = await this.getStorage().uploadFile(body, filename);
return c.json(await this.getStorage().uploadFile(file, filename));
return c.json(res, HttpStatus.CREATED);
}); });
// add upload file to entity // add upload file to entity
@@ -89,23 +102,21 @@ export class MediaController extends Controller {
overwrite: Type.Optional(booleanLike) overwrite: Type.Optional(booleanLike)
}) })
), ),
uploadSizeMiddleware,
async (c) => { async (c) => {
const entity_name = c.req.param("entity"); const entity_name = c.req.param("entity");
const field_name = c.req.param("field"); const field_name = c.req.param("field");
const entity_id = Number.parseInt(c.req.param("id")); const entity_id = Number.parseInt(c.req.param("id"));
console.log("params", { entity_name, field_name, entity_id });
// check if entity exists // check if entity exists
const entity = this.media.em.entity(entity_name); const entity = this.media.em.entity(entity_name);
if (!entity) { if (!entity) {
return c.json({ error: `Entity "${entity_name}" not found` }, 404); return c.json({ error: `Entity "${entity_name}" not found` }, HttpStatus.NOT_FOUND);
} }
// check if field exists and is of type MediaField // check if field exists and is of type MediaField
const field = entity.field(field_name); const field = entity.field(field_name);
if (!field || !(field instanceof MediaField)) { if (!field || !(field instanceof MediaField)) {
return c.json({ error: `Invalid field "${field_name}"` }, 400); return c.json({ error: `Invalid field "${field_name}"` }, HttpStatus.BAD_REQUEST);
} }
const media_entity = this.media.getMediaEntity().name as "media"; const media_entity = this.media.getMediaEntity().name as "media";
@@ -127,7 +138,10 @@ export class MediaController extends Controller {
if (count >= max_items) { if (count >= max_items) {
// if overwrite not set, abort early // if overwrite not set, abort early
if (!overwrite) { if (!overwrite) {
return c.json({ error: `Max items (${max_items}) reached` }, 400); return c.json(
{ error: `Max items (${max_items}) reached` },
HttpStatus.BAD_REQUEST
);
} }
// if already more in database than allowed, abort early // if already more in database than allowed, abort early
@@ -135,7 +149,7 @@ export class MediaController extends Controller {
if (count > max_items) { if (count > max_items) {
return c.json( return c.json(
{ error: `Max items (${max_items}) exceeded already with ${count} items.` }, { error: `Max items (${max_items}) exceeded already with ${count} items.` },
400 HttpStatus.UNPROCESSABLE_ENTITY
); );
} }
@@ -161,13 +175,23 @@ export class MediaController extends Controller {
if (!exists) { if (!exists) {
return c.json( return c.json(
{ error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` }, { error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` },
404 HttpStatus.NOT_FOUND
); );
} }
const file = await this.getStorage().getFileFromRequest(c); const file = await getFileFromContext(c);
const file_name = getRandomizedFilename(file as File); if (!file) {
const info = await this.getStorage().uploadFile(file, file_name, true); return c.json({ error: "No file provided" }, HttpStatus.BAD_REQUEST);
}
if (file.size > maxSize) {
return c.json(
{ error: `Max size (${maxSize} bytes) exceeded` },
HttpStatus.PAYLOAD_TOO_LARGE
);
}
const filename = getRandomizedFilename(file as File);
const info = await this.getStorage().uploadFile(file, filename, true);
const mutator = this.media.em.mutator(media_entity); const mutator = this.media.em.mutator(media_entity);
mutator.__unstable_toggleSystemEntityCreation(false); mutator.__unstable_toggleSystemEntityCreation(false);
@@ -185,10 +209,10 @@ export class MediaController extends Controller {
} }
} }
return c.json({ ok: true, result: result.data, ...info }); return c.json({ ok: true, result: result.data, ...info }, HttpStatus.CREATED);
} }
); );
return hono; return hono.all("*", (c) => c.notFound());
} }
} }
+2 -1
View File
@@ -16,7 +16,8 @@ export function buildMediaSchema() {
config: adapter.schema config: adapter.schema
}, },
{ {
title: name, title: adapter.schema?.title ?? name,
description: adapter.schema?.description,
additionalProperties: false additionalProperties: false
} }
); );
+32 -121
View File
@@ -1,7 +1,6 @@
import { type EmitsEvents, EventManager } from "core/events"; import { type EmitsEvents, EventManager } from "core/events";
import type { TSchema } from "core/utils"; import { type TSchema, isFile } from "core/utils";
import { type Context, Hono } from "hono"; import { isMimeType } from "media/storage/mime-types-tiny";
import { bodyLimit } from "hono/body-limit";
import * as StorageEvents from "./events"; import * as StorageEvents from "./events";
import type { FileUploadedEventData } from "./events"; import type { FileUploadedEventData } from "./events";
@@ -12,7 +11,7 @@ export type FileListObject = {
}; };
export type FileMeta = { type: string; size: number }; export type FileMeta = { type: string; size: number };
export type FileBody = ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob | File; export type FileBody = ReadableStream | File;
export type FileUploadPayload = { export type FileUploadPayload = {
name: string; name: string;
meta: FileMeta; meta: FileMeta;
@@ -38,7 +37,7 @@ export interface StorageAdapter {
} }
export type StorageConfig = { export type StorageConfig = {
body_max_size: number; body_max_size?: number;
}; };
export class Storage implements EmitsEvents { export class Storage implements EmitsEvents {
@@ -55,7 +54,7 @@ export class Storage implements EmitsEvents {
this.#adapter = adapter; this.#adapter = adapter;
this.config = { this.config = {
...config, ...config,
body_max_size: config.body_max_size ?? 20 * 1024 * 1024 body_max_size: config.body_max_size
}; };
this.emgr = emgr ?? new EventManager(); this.emgr = emgr ?? new EventManager();
@@ -82,26 +81,33 @@ export class Storage implements EmitsEvents {
noEmit?: boolean noEmit?: boolean
): Promise<FileUploadedEventData> { ): Promise<FileUploadedEventData> {
const result = await this.#adapter.putObject(name, file); const result = await this.#adapter.putObject(name, file);
console.log("result", result); if (typeof result === "undefined") {
throw new Error("Failed to upload file");
}
let info: FileUploadPayload; let info: FileUploadPayload = {
name,
meta: {
size: 0,
type: "application/octet-stream"
},
etag: typeof result === "string" ? result : ""
};
switch (typeof result) { if (typeof result === "object") {
case "undefined": info = result;
throw new Error("Failed to upload file"); } else if (isFile(file)) {
case "string": { info.meta.size = file.size;
// get object meta info.meta.type = file.type;
const meta = await this.#adapter.getObjectMeta(name); }
if (!meta) {
throw new Error("Failed to get object meta");
}
info = { name, meta, etag: result }; // try to get better meta info
break; if (!isMimeType(info?.meta.type, ["application/octet-stream", "application/json"])) {
const meta = await this.#adapter.getObjectMeta(name);
if (!meta) {
throw new Error("Failed to get object meta");
} }
case "object": info.meta = meta;
info = result;
break;
} }
const eventData = { const eventData = {
@@ -113,7 +119,10 @@ export class Storage implements EmitsEvents {
} }
}; };
if (!noEmit) { 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; return eventData;
@@ -127,102 +136,4 @@ export class Storage implements EmitsEvents {
async fileExists(name: string) { async fileExists(name: string) {
return await this.#adapter.objectExists(name); return await this.#adapter.objectExists(name);
} }
getController(): any {
// @todo: multiple providers?
// @todo: implement range requests
const hono = new Hono();
// get files list (temporary)
hono.get("/files", async (c) => {
const files = await this.#adapter.listObjects();
return c.json(files);
});
// get file by name
hono.get("/file/:filename", async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
//console.log("getting file", filename, headersToObject(c.req.raw.headers));
await this.emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
return await this.#adapter.getObject(filename, c.req.raw.headers);
});
// delete a file by name
hono.delete("/file/:filename", async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
await this.deleteFile(filename);
return c.json({ message: "File deleted" });
});
// upload file
hono.post(
"/upload/:filename",
bodyLimit({
maxSize: this.config.body_max_size,
onError: (c: any) => {
return c.text(`Payload exceeds ${this.config.body_max_size}`, 413);
}
}),
async (c) => {
const { filename } = c.req.param();
if (!filename) {
throw new Error("No file name provided");
}
const file = await this.getFileFromRequest(c);
return c.json(await this.uploadFile(file, filename));
}
);
return hono;
}
/**
* If uploaded through HttpPie -> ReadableStream
* If uploaded in tests -> file == ReadableStream
* If uploaded in FE -> content_type:body multipart/form-data; boundary=----WebKitFormBoundary7euoBFF12B0AHWLn
* file File {
* size: 223052,
* type: 'image/png',
* name: 'noise_white.png',
* lastModified: 1731743671176
* }
* @param c
*/
async getFileFromRequest(c: Context): Promise<FileBody> {
const content_type = c.req.header("Content-Type") ?? "application/octet-stream";
console.log("content_type:body", content_type);
const body = c.req.raw.body;
if (!body) {
throw new Error("No body");
}
let file: FileBody | undefined;
if (content_type?.startsWith("multipart/form-data")) {
file = (await c.req.formData()).get("file") as File;
// @todo: check nextjs, it's not *that* [File] type (but it's uploadable)
if (typeof file === "undefined") {
throw new Error("No file given at form data 'file'");
}
/*console.log("file", file);
if (!(file instanceof File)) {
throw new Error("No file given at form data 'file'");
}*/
} else if (content_type?.startsWith("application/octet-stream")) {
file = body;
} else {
throw new Error(`Unsupported content type: ${content_type}`);
}
return file;
}
} }
@@ -9,16 +9,10 @@ export const cloudinaryAdapterConfig = Type.Object(
api_secret: Type.String(), api_secret: Type.String(),
upload_preset: Type.Optional(Type.String()) upload_preset: Type.Optional(Type.String())
}, },
{ title: "Cloudinary" } { title: "Cloudinary", description: "Cloudinary media storage" }
); );
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>; export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
/*export type CloudinaryConfig = {
cloud_name: string;
api_key: string;
api_secret: string;
upload_preset?: string;
};*/
type CloudinaryObject = { type CloudinaryObject = {
asset_id: string; asset_id: string;
@@ -91,10 +85,8 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
} }
async putObject(_key: string, body: FileBody) { async putObject(_key: string, body: FileBody) {
//console.log("_key", _key);
// remove extension, as it is added by cloudinary // remove extension, as it is added by cloudinary
const key = _key.replace(/\.[a-z0-9]{2,5}$/, ""); const key = _key.replace(/\.[a-z0-9]{2,5}$/, "");
//console.log("key", key);
const formData = new FormData(); const formData = new FormData();
formData.append("file", body as any); formData.append("file", body as any);
@@ -117,21 +109,12 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
body: formData body: formData
} }
); );
//console.log("putObject:cloudinary", formData);
if (!result.ok) { if (!result.ok) {
/*console.log(
"failed to upload using cloudinary",
Object.fromEntries(formData.entries()),
result
);*/
return undefined; return undefined;
} }
//console.log("putObject:result", result);
const data = (await result.json()) as CloudinaryPutObjectResponse; const data = (await result.json()) as CloudinaryPutObjectResponse;
//console.log("putObject:result:json", data);
return { return {
name: data.public_id + "." + data.format, name: data.public_id + "." + data.format,
@@ -154,7 +137,6 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
} }
} }
); );
//console.log("result", result);
if (!result.ok) { if (!result.ok) {
throw new Error("Failed to list objects"); throw new Error("Failed to list objects");
@@ -179,10 +161,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
} }
async objectExists(key: string): Promise<boolean> { async objectExists(key: string): Promise<boolean> {
//console.log("--object exists?", key);
const result = await this.headObject(key); const result = await this.headObject(key);
//console.log("object exists", result);
return result.ok; return result.ok;
} }
@@ -214,12 +193,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
const type = this.guessType(key) ?? "image"; const type = this.guessType(key) ?? "image";
const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`; const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`;
//console.log("objectUrl", objectUrl);
return objectUrl; return objectUrl;
} }
async getObject(key: string, headers: Headers): Promise<Response> { async getObject(key: string, headers: Headers): Promise<Response> {
//console.log("url", this.getObjectUrl(key));
const res = await fetch(this.getObjectUrl(key), { const res = await fetch(this.getObjectUrl(key), {
method: "GET", method: "GET",
headers: pickHeaders(headers, ["range"]) headers: pickHeaders(headers, ["range"])
@@ -237,14 +214,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
const formData = new FormData(); const formData = new FormData();
formData.append("public_ids[]", key); formData.append("public_ids[]", key);
const result = await fetch( await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, {
`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, method: "DELETE",
{ body: formData
method: "DELETE", });
body: formData
}
);
//console.log("deleteObject:result", result);
} }
toJSON(secrets?: boolean) { toJSON(secrets?: boolean) {
@@ -1,13 +1,19 @@
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, isFile, parse } from "core/utils";
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage"; import type {
FileBody,
FileListObject,
FileMeta,
FileUploadPayload,
StorageAdapter
} from "../../Storage";
import { guess } from "../../mime-types-tiny"; import { guess } from "../../mime-types-tiny";
export const localAdapterConfig = Type.Object( export const localAdapterConfig = Type.Object(
{ {
path: Type.String({ default: "./" }) path: Type.String({ default: "./" })
}, },
{ title: "Local" } { title: "Local", description: "Local file system storage" }
); );
export type LocalAdapterConfig = Static<typeof localAdapterConfig>; export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
@@ -43,8 +49,9 @@ export class StorageLocalAdapter implements StorageAdapter {
return fileStats; return fileStats;
} }
private async computeEtag(content: BufferSource): Promise<string> { private async computeEtag(body: FileBody): Promise<string> {
const hashBuffer = await crypto.subtle.digest("SHA-256", content); const content = isFile(body) ? body : new Response(body);
const hashBuffer = await crypto.subtle.digest("SHA-256", await content.arrayBuffer());
const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join(""); const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
@@ -52,17 +59,16 @@ export class StorageLocalAdapter implements StorageAdapter {
return `"${hashHex}"`; return `"${hashHex}"`;
} }
async putObject(key: string, body: FileBody): Promise<string> { async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
if (body === null) { if (body === null) {
throw new Error("Body is empty"); throw new Error("Body is empty");
} }
// @todo: this is too hacky
const file = body as File;
const filePath = `${this.config.path}/${key}`; const filePath = `${this.config.path}/${key}`;
await writeFile(filePath, file.stream()); const is_file = isFile(body);
return await this.computeEtag(await file.arrayBuffer()); await writeFile(filePath, is_file ? body.stream() : body);
return await this.computeEtag(body);
} }
async deleteObject(key: string): Promise<void> { async deleteObject(key: string): Promise<void> {
@@ -7,7 +7,7 @@ import type {
PutObjectRequest PutObjectRequest
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import { AwsClient, isDebug } from "core"; import { AwsClient, isDebug } from "core";
import { type Static, Type, parse, pickHeaders } from "core/utils"; import { type Static, Type, isFile, parse, pickHeaders } from "core/utils";
import { transform } from "lodash-es"; import { transform } from "lodash-es";
import type { FileBody, FileListObject, StorageAdapter } from "../Storage"; import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
}) })
}, },
{ {
title: "S3" title: "AWS S3",
description: "AWS S3 or compatible storage"
} }
); );
@@ -81,17 +82,14 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
}; };
const url = this.getUrl("", params); const url = this.getUrl("", params);
//console.log("url", url);
const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, { const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
method: "GET" method: "GET"
}); });
//console.log("res", res);
// absolutely weird, but if only one object is there, it's an object, not an array // absolutely weird, but if only one object is there, it's an object, not an array
const { Contents } = res.ListBucketResult; const { Contents } = res.ListBucketResult;
const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents]; const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents];
//console.log(JSON.stringify(res.ListBucketResult, null, 2), objects);
const transformed = transform( const transformed = transform(
objects, objects,
(acc, obj) => { (acc, obj) => {
@@ -106,28 +104,21 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
}, },
[] as FileListObject[] [] as FileListObject[]
); );
//console.log(transformed);
return transformed; return transformed;
} }
async putObject( async putObject(
key: string, key: string,
body: FileBody | null, body: FileBody,
// @todo: params must be added as headers, skipping for now // @todo: params must be added as headers, skipping for now
params: Omit<PutObjectRequest, "Bucket" | "Key"> = {} params: Omit<PutObjectRequest, "Bucket" | "Key"> = {}
) { ) {
const url = this.getUrl(key, {}); const url = this.getUrl(key, {});
//console.log("url", url);
const res = await this.fetch(url, { const res = await this.fetch(url, {
method: "PUT", method: "PUT",
body body
}); });
/*console.log("putObject:raw:res", {
ok: res.ok,
status: res.status,
statusText: res.statusText,
});*/
if (res.ok) { if (res.ok) {
// "df20fcb574dba1446cf5ec997940492b" // "df20fcb574dba1446cf5ec997940492b"
+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"; import type { FileBody, FileUploadPayload } from "../Storage";
export type FileUploadedEventData = FileUploadPayload & { export type FileUploadedEventData = FileUploadPayload & {
file: FileBody; file: FileBody;
}; };
export class FileUploadedEvent extends Event<FileUploadedEventData> { export class FileUploadedEvent extends Event<FileUploadedEventData, object> {
static override slug = "file-uploaded"; 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 }> { export class FileDeletedEvent extends Event<{ name: string }> {
+37
View File
@@ -75,3 +75,40 @@ export function guess(f: string): string {
return c.a(); return c.a();
} }
} }
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;
}
}
return false;
}
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;
}
}
return "";
}
+11 -3
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; if (!filename.includes(".")) return;
const parts = filename.split("."); const parts = filename.split(".");
@@ -17,5 +18,12 @@ export function getRandomizedFilename(file: File | string, length = 16): string
throw new Error("Invalid file name"); throw new Error("Invalid file name");
} }
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(".");
} }
+7 -6
View File
@@ -18,13 +18,14 @@ import { isEqual } from "lodash-es";
export type ServerEnv = { export type ServerEnv = {
Variables: { Variables: {
app?: App; app: App;
// to prevent resolving auth multiple times // to prevent resolving auth multiple times
auth_resolved?: boolean; auth?: {
// to only register once resolved: boolean;
auth_registered?: boolean; registered: boolean;
// whether or not to bypass auth skip: boolean;
auth_skip?: boolean; user?: { id: any; role?: string; [key: string]: any };
};
html?: string; html?: string;
}; };
}; };

Some files were not shown because too many files have changed in this diff Show More