mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ecfcb3e05 | |||
| fb3dbd343b | |||
| 060df210f9 | |||
| fb725be0ed | |||
| 1a95b4c8bb | |||
| cfa950aa46 | |||
| 5771298bfa | |||
| 94c40de011 | |||
| eaa7276173 | |||
| 89871a8ef7 | |||
| 7f0f6bec08 | |||
| 8e1ad98964 | |||
| a29e824ecc | |||
| b436758fe6 | |||
| ee7dcd44f4 | |||
| f2e5815e24 | |||
| 335fe42a22 | |||
| a01b99be27 | |||
| a4e46a2768 | |||
| f494735a79 | |||
| 400db84dd5 | |||
| 964bacd824 | |||
| 7994eabe7d | |||
| dec382b49d | |||
| 93cee4f8aa | |||
| e99abb09b6 | |||
| a5ab102b34 | |||
| 1ca6c5b43d | |||
| 64658184ac | |||
| 61ad4a3109 | |||
| e12ed1e4b3 | |||
| e103d3ac33 | |||
| e2217c411a | |||
| 3e6d381239 | |||
| 0823b182f6 | |||
| bd362607ae | |||
| 61ac31eba9 | |||
| 21a8f56c48 | |||
| fb76f8d789 | |||
| 4bd201d454 | |||
| b412da836c | |||
| 425465becd | |||
| 3b487ade2a | |||
| 7d3c76d7c1 | |||
| fd297c6c50 | |||
| 149a6d768c | |||
| 24e69eec90 | |||
| 9c38c8f2ad | |||
| f0bdb3a75e | |||
| c4e505582b | |||
| cc938db4b8 | |||
| 68277a68d5 | |||
| 8e72b5b615 | |||
| 07422f30ba | |||
| d84731d89e | |||
| 4afea14893 | |||
| 2c6a4d2bed | |||
| f466dd166d | |||
| 6d8f86b2c9 | |||
| c8fa704e32 | |||
| 61d06973a4 | |||
| 7a6321f4ce | |||
| 4dde67ca21 | |||
| f9c612e0fa | |||
| d7e772efd9 | |||
| be39d1c374 | |||
| 1e5c0dbc22 | |||
| d79da9d204 | |||
| 09d35d3cc0 | |||
| f7e6928dba | |||
| 3fa682bfe1 | |||
| 97b0ff24c8 | |||
| bb964dc835 | |||
| 5840ec6b89 | |||
| f29641c702 | |||
| 2e3ee65aa7 | |||
| 11a6f5c9b9 | |||
| 324d641410 | |||
| 02e7e1ca95 | |||
| 78d4f928ba | |||
| 0a5cc2ad98 | |||
| e14d528386 | |||
| 15003b004b | |||
| 9a4c2bd530 | |||
| ab61a1392c | |||
| 53d55448e6 | |||
| 46cf310ad6 | |||
| 8418231c43 | |||
| 4b3493a6f5 | |||
| f432473ed9 | |||
| 8f0cef1f37 | |||
| a8e78f984d | |||
| babeaae8d1 | |||
| 45d44f8177 | |||
| db476080ad | |||
| 158818520b | |||
| 4755288fed | |||
| 10db267164 | |||
| dfe7d55057 | |||
| c2b3316fcb | |||
| 86ba055f5e | |||
| 9d6ccda20d | |||
| 39c419c320 | |||
| 2d6d83ccb2 | |||
| ba1eb5fe08 | |||
| 123389786d | |||
| b65bb3b826 | |||
| 5b40d5eaf4 | |||
| 89b1fd3909 | |||
| aa7eac258b | |||
| 392a06c486 | |||
| 1beac5043b | |||
| 475488c910 | |||
| 9ddacb7ae5 | |||
| 3d1d52cbf8 | |||
| acc504cdc9 | |||
| d98d3351c0 | |||
| 3e606cb159 | |||
| cc76964d03 | |||
| b0c5f6307a | |||
| b3019d7333 | |||
| 2265451d4b | |||
| ba936604a3 | |||
| 7dd42fbff3 | |||
| f64e5dac03 |
@@ -19,6 +19,7 @@ packages/media/.env
|
||||
.history
|
||||
**/*/.db/*
|
||||
**/*/.configs/*
|
||||
**/*/.template/*
|
||||
**/*/*.db
|
||||
**/*/*.db-shm
|
||||
**/*/*.db-wal
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
[](https://npmjs.org/package/bknd
|
||||
"View this project on NPM")
|
||||
[](https://npmjs.org/package/bknd)
|
||||
[](https://www.npmjs.com/package/bknd)
|
||||
|
||||

|
||||
|
||||
bknd simplifies app development by providing fully functional backend for data management,
|
||||
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
|
||||
be deployed nearly anywhere, including running inside your framework of choice. No more
|
||||
deploying multiple separate services!
|
||||
<p align="center" width="100%">
|
||||
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema">
|
||||
<strong>⭐ Live Demo</strong>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
bknd simplifies app development by providing a fully functional backend for database management, authentication, media and workflows. Being lightweight and built on Web Standards, it can be deployed nearly anywhere, including running inside your framework of choice. No more deploying multiple separate services!
|
||||
|
||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||
|
||||
@@ -15,33 +17,116 @@ deploying multiple separate services!
|
||||
> Please keep in mind that **bknd** is still under active development
|
||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||
|
||||
## ✨ Features
|
||||
**📊 Data**: Define, query, and control your data with ease.
|
||||
- Define entities with fields and relationships, synced directly to your database.
|
||||
- Supported field types: `primary`, `text`, `number`, `date`, `boolean`, `enum`, `json`, `jsonschema`.
|
||||
- Relationship types: `one-to-one`, `many-to-one`, `many-to-many`, and `polymorphic`.
|
||||
- Advanced querying with the **Repository**: filtering, sorting, pagination, and relational data handling.
|
||||
- Seamlessly manage data with mutators and a robust event system.
|
||||
- Extend database functionality with batching, introspection, and support for multiple SQL dialects.
|
||||
## Size
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**🔐 Auth**: Easily implement reliable authentication strategies.
|
||||
- Built-in `user` entity with customizable fields.
|
||||
- Supports multiple authentication strategies:
|
||||
- Email/password (with hashed storage).
|
||||
- OAuth/OIDC (Google, GitHub, and more).
|
||||
- Secure JWT generation and session management.
|
||||
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.
|
||||
|
||||
**🖼️ Media**: Effortlessly manage and serve all your media files.
|
||||
- Upload files with ease.
|
||||
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
|
||||
## Motivation
|
||||
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
|
||||
|
||||
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation.
|
||||
- Create and run workflows with trigger-based automation:
|
||||
- Manual triggers or events from data, auth, media, or server actions.
|
||||
- HTTP triggers for external integrations.
|
||||
- Define tasks in sequence, parallel, or loops, with conditional execution.
|
||||
- Use reusable sub-workflows to organize complex processes.
|
||||
- Leverage OpenAPI specifications for API-based tasks.
|
||||
**The solution:** A backend system that only assumes and implements primitive details, integrates into multiple environments, and adheres to industry standards.
|
||||
|
||||
## Features
|
||||
* ⚡ Instant backend with full REST API:
|
||||
* **Data**: Define, query, and control your data with ease.
|
||||
* **Auth**: Easily implement reliable authentication strategies.
|
||||
* **Media**: Effortlessly manage and serve all your media files.
|
||||
* **Flows**: Design and run workflows with seamless automation. (UI integration coming soon!)
|
||||
* 🌐 Built on Web Standards for maximum compatibility
|
||||
* 🏃♂️ Multiple run modes
|
||||
* standalone using the CLI
|
||||
* using a JavaScript runtime (Node, Bun, workerd)
|
||||
* using a React framework (Astro, Remix, Next.js)
|
||||
* 📦 Official API and React SDK with type-safety
|
||||
* ⚛️ React elements for auto-configured authentication and media components
|
||||
|
||||
## Structure
|
||||
The package is mainly split into 4 parts, each serving a specific purpose:
|
||||
|
||||
| Import | Purpose |
|
||||
|-----------------------------|------------------------------------------------------|
|
||||
| `bknd`<br/>`bknd/adapter/*` | Backend including APIs and adapters |
|
||||
| `bknd/ui` | Admin UI components for react frameworks |
|
||||
| `bknd/client` | TypeScript SDK and React hooks for the API endpoints |
|
||||
| `bknd/elements` | React components for authentication and media |
|
||||
|
||||
|
||||
### The backend (`bknd`)
|
||||
Serve the backend as an API for any JS runtime or framework. The latter is especially handy, as it allows you to deploy your frontend and backend bundled together. Furthermore it allows adding additional logic in a way you're already familar with. Just add another route and you're good to go.
|
||||
|
||||
Here is an example of serving the API using node:
|
||||
```js index.js
|
||||
import { serve } from "bknd/adapter/node"
|
||||
serve();
|
||||
```
|
||||
|
||||
### Integrated admin UI (`bknd/ui`)
|
||||
The admin UI allows to manage your data including full configuration of your backend using a graphical user interface. Using `vite`, your admin route looks like this:
|
||||
```tsx
|
||||
import { Admin } from "bknd/ui"
|
||||
import "bknd/dist/styles.css";
|
||||
|
||||
export default function AdminPage() {
|
||||
return <Admin />
|
||||
}
|
||||
```
|
||||
|
||||
### Using the REST API or TypeScript SDK (`bknd/client`)
|
||||
If you're not using a JavaScript environment, you can still access any endpoint using the REST API:
|
||||
```bash
|
||||
curl -XGET <your-endpoint>/api/data/<entity>
|
||||
{
|
||||
"data": [
|
||||
{ "id": 1, ... },
|
||||
{ "id": 2, ... }
|
||||
],
|
||||
"meta": { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
In a JavaScript environment, you can use the TypeScript SDK with type-safety. The above example would look like this:
|
||||
```ts
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
const api = new Api({ host: "<endpoint>" });
|
||||
const { data } = await api.data.readMany("<entity>");
|
||||
```
|
||||
|
||||
If you're using React, there are 2 hooks exposed (`useApi`, `useEntity`), as well as an `swr` wrapper around each (`useApiQuery`, `useEntityQuery`). The `swr` wrapped hooks automatically handled query invalidation:
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { useEntityQuery } from "bknd/client";
|
||||
|
||||
export default function App() {
|
||||
const { data } = useEntityQuery("todos");
|
||||
return <ul>
|
||||
{data?.map(todo => (
|
||||
<li key={todo.id}>{todo.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
```
|
||||
|
||||
### React elements (`bknd/elements`)
|
||||
You don't have to figure out API details to include media uploads to your app. For an user avatar upload, this is all you need:
|
||||
```tsx
|
||||
import { Media } from "bknd/elements"
|
||||
import "bknd/dist/main.css"
|
||||
|
||||
export function UserAvatar() {
|
||||
return <Media.Dropzone
|
||||
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||
maxItems={1}
|
||||
overwrite
|
||||
/>
|
||||
}
|
||||
```
|
||||
The import path also exports components for login and registration forms which are automatically pointed to the `bknd` defaults.
|
||||
|
||||
|
||||
## 🚀 Quick start
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
tmp/*
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,11 @@ describe("DataApi", () => {
|
||||
|
||||
const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
|
||||
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"] });
|
||||
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 () => {
|
||||
@@ -39,7 +39,7 @@ describe("DataApi", () => {
|
||||
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();
|
||||
expect(data.length).toEqual(3);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -27,10 +27,7 @@ describe("ModuleApi", () => {
|
||||
|
||||
it("fetches endpoint", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
|
||||
// @ts-expect-error it's protected
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
const api = new Api({ host }, app.request as typeof fetch);
|
||||
|
||||
const res = await api.get("/endpoint");
|
||||
expect(res.res.ok).toEqual(true);
|
||||
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
|
||||
|
||||
it("has accessible request", async () => {
|
||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||
const api = new Api({ host });
|
||||
|
||||
// @ts-expect-error it's protected
|
||||
api.fetcher = app.request as typeof fetch;
|
||||
const api = new Api({ host }, app.request as typeof fetch);
|
||||
|
||||
const promise = api.get("/endpoint");
|
||||
expect(promise.request).toBeDefined();
|
||||
|
||||
@@ -70,4 +70,34 @@ describe("repros", async () => {
|
||||
|
||||
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"
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,10 +16,8 @@ describe("authorize", () => {
|
||||
role: "admin"
|
||||
};
|
||||
|
||||
guard.setUserContext(user);
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
|
||||
expect(() => guard.granted("something")).toThrow();
|
||||
});
|
||||
@@ -46,10 +44,8 @@ describe("authorize", () => {
|
||||
role: "admin"
|
||||
};
|
||||
|
||||
guard.setUserContext(user);
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
});
|
||||
|
||||
test("guard implicit allow", async () => {
|
||||
@@ -66,12 +62,12 @@ describe("authorize", () => {
|
||||
}
|
||||
});
|
||||
|
||||
guard.setUserContext({
|
||||
const user = {
|
||||
role: "admin"
|
||||
});
|
||||
};
|
||||
|
||||
expect(guard.granted("read")).toBe(true);
|
||||
expect(guard.granted("write")).toBe(true);
|
||||
expect(guard.granted("read", user)).toBe(true);
|
||||
expect(guard.granted("write", user)).toBe(true);
|
||||
});
|
||||
|
||||
test("guard with guest role implicit allow", async () => {
|
||||
|
||||
@@ -111,6 +111,7 @@ describe("EventManager", async () => {
|
||||
emgr.onEvent(ReturnEvent, async () => "1", "sync");
|
||||
emgr.onEvent(ReturnEvent, async () => "0", "sync");
|
||||
|
||||
// @todo: fix this
|
||||
// @ts-expect-error must be string
|
||||
emgr.onEvent(ReturnEvent, async () => 0, "sync");
|
||||
|
||||
|
||||
+150
-13
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Perf } from "../../src/core/utils";
|
||||
import * as reqres from "../../src/core/utils/reqres";
|
||||
import * as strings from "../../src/core/utils/strings";
|
||||
import { Perf, isBlob, ucFirst } from "../../src/core/utils";
|
||||
import * as utils from "../../src/core/utils";
|
||||
|
||||
async function wait(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
|
||||
describe("[core] strings", async () => {
|
||||
test("objectToKeyValueArray", async () => {
|
||||
const obj = { a: 1, b: 2, c: 3 };
|
||||
const result = strings.objectToKeyValueArray(obj);
|
||||
const result = utils.objectToKeyValueArray(obj);
|
||||
expect(result).toEqual([
|
||||
{ key: "a", value: 1 },
|
||||
{ key: "b", value: 2 },
|
||||
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
|
||||
});
|
||||
|
||||
test("snakeToPascalWithSpaces", async () => {
|
||||
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
|
||||
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
|
||||
expect(result).toBe("Snake To Pascal");
|
||||
});
|
||||
|
||||
test("randomString", async () => {
|
||||
const result = strings.randomString(10);
|
||||
const result = utils.randomString(10);
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
test("pascalToKebab", async () => {
|
||||
const result = strings.pascalToKebab("PascalCase");
|
||||
const result = utils.pascalToKebab("PascalCase");
|
||||
expect(result).toBe("pascal-case");
|
||||
});
|
||||
|
||||
test("replaceSimplePlaceholders", async () => {
|
||||
const str = "Hello, {$name}!";
|
||||
const vars = { name: "John" };
|
||||
const result = strings.replaceSimplePlaceholders(str, vars);
|
||||
const result = utils.replaceSimplePlaceholders(str, vars);
|
||||
expect(result).toBe("Hello, John!");
|
||||
});
|
||||
});
|
||||
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/json");
|
||||
headers.append("Authorization", "Bearer 123");
|
||||
const obj = reqres.headersToObject(headers);
|
||||
const obj = utils.headersToObject(headers);
|
||||
expect(obj).toEqual({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer 123"
|
||||
@@ -59,23 +58,74 @@ describe("Core Utils", async () => {
|
||||
test("replaceUrlParam", () => {
|
||||
const url = "/api/:id/:name";
|
||||
const params = { id: "123", name: "test" };
|
||||
const result = reqres.replaceUrlParam(url, params);
|
||||
const result = utils.replaceUrlParam(url, params);
|
||||
expect(result).toBe("/api/123/test");
|
||||
});
|
||||
|
||||
test("encode", () => {
|
||||
const obj = { id: "123", name: "test" };
|
||||
const result = reqres.encodeSearch(obj);
|
||||
const result = utils.encodeSearch(obj);
|
||||
expect(result).toBe("id=123&name=test");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -108,4 +158,91 @@ describe("Core Utils", async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("[data] DataController", async () => {
|
||||
//console.log("app.routes", app.routes);
|
||||
// create users
|
||||
for await (const _user of fixtures.users) {
|
||||
const res = await app.request("/users", {
|
||||
const res = await app.request("/entity/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(_user)
|
||||
});
|
||||
@@ -131,7 +131,7 @@ describe("[data] DataController", async () => {
|
||||
|
||||
// create posts
|
||||
for await (const _post of fixtures.posts) {
|
||||
const res = await app.request("/posts", {
|
||||
const res = await app.request("/entity/posts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(_post)
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe("[data] DataController", 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;
|
||||
|
||||
expect(data.meta.total).toBe(3);
|
||||
@@ -156,7 +156,7 @@ describe("[data] DataController", async () => {
|
||||
});
|
||||
|
||||
test("/:entity/query (func query)", async () => {
|
||||
const res = await app.request("/users/query", {
|
||||
const res = await app.request("/entity/users/query", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
@@ -175,7 +175,7 @@ describe("[data] DataController", 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;
|
||||
|
||||
expect(data.meta.total).toBe(3);
|
||||
@@ -186,7 +186,7 @@ describe("[data] DataController", 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>;
|
||||
console.log("data", data);
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("[data] DataController", async () => {
|
||||
});
|
||||
|
||||
test("/:entity (update one)", async () => {
|
||||
const res = await app.request("/users/3", {
|
||||
const res = await app.request("/entity/users/3", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: "new name" })
|
||||
});
|
||||
@@ -208,7 +208,7 @@ describe("[data] DataController", 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;
|
||||
console.log("data", data);
|
||||
|
||||
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
|
||||
});
|
||||
|
||||
test("/:entity/:id (delete one)", async () => {
|
||||
const res = await app.request("/posts/2", {
|
||||
const res = await app.request("/entity/posts/2", {
|
||||
method: "DELETE"
|
||||
});
|
||||
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
|
||||
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
||||
|
||||
// verify
|
||||
const res2 = await app.request("/posts");
|
||||
const res2 = await app.request("/entity/posts");
|
||||
const data2 = (await res2.json()) as RepositoryResponse;
|
||||
expect(data2.meta.total).toBe(1);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,3 +68,6 @@ export function schemaToEm(s: ReturnType<typeof protoEm>, conn?: Connection): En
|
||||
const connection = conn ? conn : getDummyConnection().dummyConnection;
|
||||
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 { App, createApp } from "../../src";
|
||||
import type { AuthResponse } from "../../src/auth";
|
||||
import { auth } from "../../src/auth/middlewares";
|
||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||
|
||||
@@ -98,7 +99,7 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
|
||||
}
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
"Content-Type": "application/json",
|
||||
...additional
|
||||
};
|
||||
@@ -210,4 +211,36 @@ describe("integration auth", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,56 +1,96 @@
|
||||
import { describe, test } from "bun:test";
|
||||
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";
|
||||
/// <reference types="@types/bun" />
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* R2
|
||||
* 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()
|
||||
};
|
||||
beforeAll(() => {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
});
|
||||
|
||||
const media = new AppMedia(
|
||||
// @ts-ignore
|
||||
{
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "s3",
|
||||
config: {
|
||||
access_key: process.env.R2_ACCESS_KEY as string,
|
||||
secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string,
|
||||
url: process.env.R2_URL as string
|
||||
const path = `${assetsPath}/image.png`;
|
||||
|
||||
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||
const app = createApp({
|
||||
initialConfig: {
|
||||
media: mergeObject(
|
||||
{
|
||||
enabled: true,
|
||||
adapter: {
|
||||
type: "local",
|
||||
config: {
|
||||
path: assetsTmpPath
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ctx
|
||||
);
|
||||
await media.build();
|
||||
const app = new MediaController(media).getController();
|
||||
},
|
||||
mediaOverride
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
const file = Bun.file(`${import.meta.dir}/adapters/icon.png`);
|
||||
console.log("file", file);
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
await app.build();
|
||||
return app;
|
||||
}
|
||||
|
||||
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",
|
||||
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 () => {
|
||||
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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { stripMark } from "../../src/core/utils";
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
|
||||
import { entity, text } from "../../src/data";
|
||||
import { Module } from "../../src/modules/Module";
|
||||
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
||||
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
||||
import { getDummyConnection } from "../helper";
|
||||
@@ -177,7 +178,7 @@ describe("ModuleManager", async () => {
|
||||
await mm.build();
|
||||
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();
|
||||
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
|
||||
await mm.save();
|
||||
@@ -252,4 +253,131 @@ describe("ModuleManager", async () => {
|
||||
});
|
||||
|
||||
// @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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { Guard } from "../../src/auth";
|
||||
import { DebugLogger } from "../../src/core";
|
||||
import { EventManager } from "../../src/core/events";
|
||||
import { Default, stripMark } from "../../src/core/utils";
|
||||
import { EntityManager } from "../../src/data";
|
||||
@@ -17,6 +18,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
||||
emgr: new EventManager(),
|
||||
guard: new Guard(),
|
||||
flags: Module.ctx_flags,
|
||||
logger: new DebugLogger(false),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -49,111 +49,117 @@ if (types && !watch) {
|
||||
/**
|
||||
* Building backend and general API
|
||||
*/
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
||||
outDir: "dist",
|
||||
external: ["bun:test", "@libsql/client"],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
async function buildApi() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
||||
outDir: "dist",
|
||||
external: ["bun:test", "@libsql/client", "bknd/client"],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Building UI for direct imports
|
||||
*/
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||
outDir: "dist/ui",
|
||||
external: [
|
||||
"bun:test",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"use-sync-external-store",
|
||||
/codemirror/,
|
||||
"@xyflow/react",
|
||||
"@mantine/core"
|
||||
],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.logLevel = "silent";
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
async function buildUi() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/ui/index.ts", "src/ui/client/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||
outDir: "dist/ui",
|
||||
external: [
|
||||
"bun:test",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"use-sync-external-store",
|
||||
/codemirror/,
|
||||
"@xyflow/react",
|
||||
"@mantine/core"
|
||||
],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.logLevel = "silent";
|
||||
},
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Building UI Elements
|
||||
* - tailwind-merge is mocked, no exclude
|
||||
* - ui/client is external, and after built replaced with "bknd/client"
|
||||
*/
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/ui/elements/index.ts"],
|
||||
outDir: "dist/ui/elements",
|
||||
external: [
|
||||
"ui/client",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"use-sync-external-store"
|
||||
],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.alias = {
|
||||
// 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
|
||||
const path = "./dist/ui/elements/index.js";
|
||||
const bundle = await Bun.file(path).text();
|
||||
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
|
||||
async function buildUiElements() {
|
||||
await tsup.build({
|
||||
minify,
|
||||
sourcemap,
|
||||
watch,
|
||||
entry: ["src/ui/elements/index.ts"],
|
||||
outDir: "dist/ui/elements",
|
||||
external: [
|
||||
"ui/client",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"react/jsx-dev-runtime",
|
||||
"use-sync-external-store"
|
||||
],
|
||||
metafile: true,
|
||||
platform: "browser",
|
||||
format: ["esm"],
|
||||
splitting: false,
|
||||
bundle: true,
|
||||
treeshake: true,
|
||||
loader: {
|
||||
".svg": "dataurl"
|
||||
},
|
||||
esbuildOptions: (options) => {
|
||||
options.alias = {
|
||||
// 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
|
||||
const path = "./dist/ui/elements/index.js";
|
||||
const bundle = await Bun.file(path).text();
|
||||
await Bun.write(path, bundle.replaceAll("ui/client", "bknd/client"));
|
||||
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
delayTypes();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Building adapters
|
||||
*/
|
||||
function baseConfig(adapter: string): tsup.Options {
|
||||
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
||||
return {
|
||||
minify,
|
||||
sourcemap,
|
||||
@@ -162,40 +168,61 @@ function baseConfig(adapter: string): tsup.Options {
|
||||
format: ["esm"],
|
||||
platform: "neutral",
|
||||
outDir: `dist/adapter/${adapter}`,
|
||||
metafile: true,
|
||||
splitting: false,
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
},
|
||||
...overrides,
|
||||
define: {
|
||||
__isDev: "0"
|
||||
__isDev: "0",
|
||||
...overrides.define
|
||||
},
|
||||
external: [
|
||||
/^cloudflare*/,
|
||||
/^@?(hono|libsql).*?/,
|
||||
/^(bknd|react|next|node).*?/,
|
||||
/.*\.(html)$/
|
||||
],
|
||||
metafile: true,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
onSuccess: async () => {
|
||||
delayTypes();
|
||||
}
|
||||
/.*\.(html)$/,
|
||||
...(Array.isArray(overrides.external) ? overrides.external : [])
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
await tsup.build(baseConfig("remix"));
|
||||
await tsup.build(baseConfig("bun"));
|
||||
await tsup.build(baseConfig("astro"));
|
||||
await tsup.build(baseConfig("cloudflare"));
|
||||
async function buildAdapters() {
|
||||
// base adapter handles
|
||||
await tsup.build({
|
||||
...baseConfig(""),
|
||||
entry: ["src/adapter/index.ts"],
|
||||
outDir: "dist/adapter"
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("vite"),
|
||||
platform: "node"
|
||||
});
|
||||
// specific adatpers
|
||||
await tsup.build(baseConfig("remix"));
|
||||
await tsup.build(baseConfig("bun"));
|
||||
await tsup.build(baseConfig("astro"));
|
||||
await tsup.build(
|
||||
baseConfig("cloudflare", {
|
||||
external: [/^kysely/]
|
||||
})
|
||||
);
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("nextjs"),
|
||||
platform: "node"
|
||||
});
|
||||
await tsup.build({
|
||||
...baseConfig("vite"),
|
||||
platform: "node"
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("node"),
|
||||
platform: "node"
|
||||
});
|
||||
await tsup.build({
|
||||
...baseConfig("nextjs"),
|
||||
platform: "node"
|
||||
});
|
||||
|
||||
await tsup.build({
|
||||
...baseConfig("node"),
|
||||
platform: "node"
|
||||
});
|
||||
}
|
||||
|
||||
await buildApi();
|
||||
await buildUi();
|
||||
await buildUiElements();
|
||||
await buildAdapters();
|
||||
|
||||
+25
-14
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"bin": "./dist/cli/index.js",
|
||||
"version": "0.6.1",
|
||||
"version": "0.8.1",
|
||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||
"homepage": "https://bknd.io",
|
||||
"repository": {
|
||||
@@ -22,9 +22,9 @@
|
||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --minify",
|
||||
"build:static": "vite build",
|
||||
"watch": "bun run build.ts --types --watch",
|
||||
"types": "bun tsc --noEmit",
|
||||
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
||||
"clean:types": "find ./dist -name '*.d.ts' -delete && rm -f ./dist/tsconfig.tsbuildinfo",
|
||||
"build:types": "tsc --emitDeclarationOnly && tsc-alias",
|
||||
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
||||
"updater": "bun x npm-check-updates -ui",
|
||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
|
||||
@@ -33,36 +33,41 @@
|
||||
"license": "FSL-1.1-MIT",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@mantine/core": "^7.13.4",
|
||||
"@sinclair/typebox": "^0.32.34",
|
||||
"@tanstack/react-form": "0.19.2",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@xyflow/react": "^12.3.2",
|
||||
"aws4fetch": "^1.0.18",
|
||||
"dayjs": "^1.11.13",
|
||||
"fast-xml-parser": "^4.4.0",
|
||||
"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",
|
||||
"liquidjs": "^10.15.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"oauth4webapi": "^2.11.1",
|
||||
"swr": "^2.2.5",
|
||||
"json-schema-form-react": "^0.0.2",
|
||||
"@uiw/react-codemirror": "^4.23.6",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@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"
|
||||
"object-path-immutable": "^4.1.2",
|
||||
"picocolors": "^1.1.1",
|
||||
"radix-ui": "^1.1.2",
|
||||
"swr": "^2.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.613.0",
|
||||
"@bluwy/giget-core": "^0.1.2",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@hono/typebox-validator": "^0.2.6",
|
||||
"@hono/vite-dev-server": "^0.17.0",
|
||||
"@hono/zod-validator": "^0.4.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@libsql/kysely-libsql": "^0.4.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@rjsf/core": "^5.22.2",
|
||||
"@tabler/icons-react": "3.18.0",
|
||||
"@types/node": "^22.10.0",
|
||||
@@ -71,8 +76,10 @@
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"esbuild-postcss": "^0.0.4",
|
||||
"jotai": "^2.10.1",
|
||||
"kysely-d1": "^0.3.0",
|
||||
"open": "^10.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"postcss": "^8.4.47",
|
||||
@@ -148,6 +155,10 @@
|
||||
"import": "./dist/adapter/cloudflare/index.js",
|
||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
||||
},
|
||||
"./adapter": {
|
||||
"types": "./dist/types/adapter/index.d.ts",
|
||||
"import": "./dist/adapter/index.js"
|
||||
},
|
||||
"./adapter/vite": {
|
||||
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||
"import": "./dist/adapter/vite/index.js",
|
||||
@@ -210,4 +221,4 @@
|
||||
"bun",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
+73
-28
@@ -17,13 +17,22 @@ declare global {
|
||||
}
|
||||
|
||||
export type ApiOptions = {
|
||||
host: string;
|
||||
user?: TApiUser;
|
||||
token?: string;
|
||||
host?: string;
|
||||
headers?: Headers;
|
||||
key?: string;
|
||||
localStorage?: boolean;
|
||||
};
|
||||
fetcher?: typeof fetch;
|
||||
verbose?: boolean;
|
||||
verified?: boolean;
|
||||
} & (
|
||||
| {
|
||||
token?: string;
|
||||
user?: TApiUser;
|
||||
}
|
||||
| {
|
||||
request: Request;
|
||||
}
|
||||
);
|
||||
|
||||
export type AuthState = {
|
||||
token?: string;
|
||||
@@ -42,14 +51,26 @@ export class Api {
|
||||
public auth!: AuthApi;
|
||||
public media!: MediaApi;
|
||||
|
||||
constructor(private readonly options: ApiOptions) {
|
||||
if (options.user) {
|
||||
this.user = options.user;
|
||||
this.token_transport = "none";
|
||||
this.verified = true;
|
||||
} else if (options.token) {
|
||||
constructor(private options: ApiOptions = {}) {
|
||||
// only mark verified if forced
|
||||
this.verified = options.verified === true;
|
||||
|
||||
// prefer request if given
|
||||
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.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 {
|
||||
this.extractToken();
|
||||
}
|
||||
@@ -58,7 +79,7 @@ export class Api {
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this.options.host;
|
||||
return this.options.host ?? "http://localhost";
|
||||
}
|
||||
|
||||
get tokenKey() {
|
||||
@@ -66,13 +87,15 @@ export class Api {
|
||||
}
|
||||
|
||||
private extractToken() {
|
||||
// if token has to be extracted, it's never verified
|
||||
this.verified = false;
|
||||
|
||||
if (this.options.headers) {
|
||||
// try cookies
|
||||
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
||||
if (cookieToken) {
|
||||
this.updateToken(cookieToken);
|
||||
this.token_transport = "cookie";
|
||||
this.verified = true;
|
||||
this.updateToken(cookieToken);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,6 +119,8 @@ export class Api {
|
||||
|
||||
updateToken(token?: string, rebuild?: boolean) {
|
||||
this.token = token;
|
||||
this.verified = false;
|
||||
|
||||
if (token) {
|
||||
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||
} else {
|
||||
@@ -115,11 +140,15 @@ export class Api {
|
||||
if (rebuild) this.buildApis();
|
||||
}
|
||||
|
||||
markAuthVerified(verfied: boolean) {
|
||||
private markAuthVerified(verfied: boolean) {
|
||||
this.verified = verfied;
|
||||
return this;
|
||||
}
|
||||
|
||||
isAuthVerified(): boolean {
|
||||
return this.verified;
|
||||
}
|
||||
|
||||
getAuthState(): AuthState {
|
||||
return {
|
||||
token: this.token,
|
||||
@@ -128,6 +157,11 @@ export class Api {
|
||||
};
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
const { token, user } = this.getAuthState();
|
||||
return !!token && !!user;
|
||||
}
|
||||
|
||||
async getVerifiedAuthState(): Promise<AuthState> {
|
||||
await this.verifyAuth();
|
||||
return this.getAuthState();
|
||||
@@ -140,11 +174,13 @@ export class Api {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.auth.me();
|
||||
if (!res.ok || !res.body.user) {
|
||||
const { ok, data } = await this.auth.me();
|
||||
const user = data?.user;
|
||||
if (!ok || !user) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.markAuthVerified(true);
|
||||
} catch (e) {
|
||||
this.markAuthVerified(false);
|
||||
@@ -156,21 +192,30 @@ export class Api {
|
||||
return this.user || null;
|
||||
}
|
||||
|
||||
private buildApis() {
|
||||
const baseParams = {
|
||||
host: this.options.host,
|
||||
getParams() {
|
||||
return Object.freeze({
|
||||
host: this.baseUrl,
|
||||
token: this.token,
|
||||
headers: this.options.headers,
|
||||
token_transport: this.token_transport
|
||||
};
|
||||
|
||||
this.system = new SystemApi(baseParams);
|
||||
this.data = new DataApi(baseParams);
|
||||
this.auth = new AuthApi({
|
||||
...baseParams,
|
||||
onTokenUpdate: (token) => this.updateToken(token, true)
|
||||
token_transport: this.token_transport,
|
||||
verbose: this.options.verbose
|
||||
});
|
||||
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
@@ -1,8 +1,12 @@
|
||||
import { Api, type ApiOptions } from "Api";
|
||||
import type { CreateUserPayload } from "auth/AppAuth";
|
||||
import { $console } from "core";
|
||||
import { Event } from "core/events";
|
||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||
import type { Hono } from "hono";
|
||||
import {
|
||||
type InitialModuleConfigs,
|
||||
type ModuleBuildContext,
|
||||
ModuleManager,
|
||||
type ModuleManagerOptions,
|
||||
type Modules
|
||||
@@ -25,16 +29,22 @@ export class AppFirstBoot extends AppEvent {
|
||||
}
|
||||
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 = {
|
||||
connection?:
|
||||
| Connection
|
||||
| {
|
||||
// @deprecated
|
||||
type: "libsql";
|
||||
config: LibSqlCredentials;
|
||||
};
|
||||
}
|
||||
| LibSqlCredentials;
|
||||
initialConfig?: InitialModuleConfigs;
|
||||
plugins?: AppPlugin[];
|
||||
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
|
||||
options?: AppOptions;
|
||||
};
|
||||
|
||||
export type AppConfig = InitialModuleConfigs;
|
||||
@@ -44,30 +54,34 @@ export class App {
|
||||
static readonly Events = AppEvents;
|
||||
adminController?: AdminController;
|
||||
private trigger_first_boot = false;
|
||||
private plugins: AppPlugin[];
|
||||
|
||||
constructor(
|
||||
private connection: Connection,
|
||||
_initialConfig?: InitialModuleConfigs,
|
||||
private plugins: AppPlugin[] = [],
|
||||
moduleManagerOptions?: ModuleManagerOptions
|
||||
private options?: AppOptions
|
||||
) {
|
||||
this.plugins = options?.plugins ?? [];
|
||||
this.modules = new ModuleManager(connection, {
|
||||
...moduleManagerOptions,
|
||||
...(options?.manager ?? {}),
|
||||
initial: _initialConfig,
|
||||
seed: options?.seed,
|
||||
onUpdated: async (key, config) => {
|
||||
// if the EventManager was disabled, we assume we shouldn't
|
||||
// respond to events, such as "onUpdated".
|
||||
// this is important if multiple changes are done, and then build() is called manually
|
||||
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;
|
||||
}
|
||||
|
||||
console.log("[APP] config updated", key);
|
||||
await this.build({ sync: true, save: true });
|
||||
$console.log("App config updated", key);
|
||||
// @todo: potentially double syncing
|
||||
await this.build({ sync: true });
|
||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||
},
|
||||
onFirstBoot: async () => {
|
||||
console.log("[APP] first boot");
|
||||
$console.log("App first boot");
|
||||
this.trigger_first_boot = true;
|
||||
},
|
||||
onServerInit: async (server) => {
|
||||
@@ -84,16 +98,10 @@ export class App {
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
// load system controller
|
||||
@@ -107,12 +115,6 @@ export class App {
|
||||
|
||||
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
|
||||
if (this.trigger_first_boot) {
|
||||
this.trigger_first_boot = false;
|
||||
@@ -121,7 +123,7 @@ export class App {
|
||||
}
|
||||
|
||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||
return this.modules.get(module).schema();
|
||||
return this.modules.mutateConfigSafe(module);
|
||||
}
|
||||
|
||||
get server() {
|
||||
@@ -132,7 +134,7 @@ export class App {
|
||||
return this.modules.ctx().em;
|
||||
}
|
||||
|
||||
get fetch(): any {
|
||||
get fetch(): Hono["fetch"] {
|
||||
return this.server.fetch;
|
||||
}
|
||||
|
||||
@@ -155,6 +157,10 @@ export class App {
|
||||
return this.modules.version();
|
||||
}
|
||||
|
||||
isBuilt(): boolean {
|
||||
return this.modules.isBuilt();
|
||||
}
|
||||
|
||||
registerAdminController(config?: AdminControllerOptions) {
|
||||
// register admin
|
||||
this.adminController = new AdminController(this, config);
|
||||
@@ -173,6 +179,15 @@ export class App {
|
||||
async createUser(p: CreateUserPayload) {
|
||||
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 = {}) {
|
||||
@@ -182,18 +197,25 @@ export function createApp(config: CreateAppConfig = {}) {
|
||||
if (Connection.isConnection(config.connection)) {
|
||||
connection = config.connection;
|
||||
} 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 {
|
||||
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) {
|
||||
console.error("Could not create connection", e);
|
||||
$console.error("Could not create connection", e);
|
||||
}
|
||||
|
||||
if (!connection) {
|
||||
throw new Error("Invalid connection");
|
||||
}
|
||||
|
||||
return new App(connection, config.initialConfig, config.plugins, config.options);
|
||||
return new App(connection, config.initialConfig, config.options);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
import { Api, type ApiOptions, type App } from "bknd";
|
||||
import 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 = {
|
||||
request: Request;
|
||||
@@ -13,18 +14,20 @@ export type Options = {
|
||||
host?: string;
|
||||
};
|
||||
|
||||
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||
return new Api({
|
||||
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||
const api = new Api({
|
||||
host: new URL(Astro.request.url).origin,
|
||||
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
|
||||
});
|
||||
await api.verifyAuth();
|
||||
return api;
|
||||
}
|
||||
|
||||
let app: App;
|
||||
export function serve(config: AstroBkndConfig = {}) {
|
||||
return async (args: TAstro) => {
|
||||
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
|
||||
return async (args: Context) => {
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config);
|
||||
app = await createFrameworkApp(config, args);
|
||||
}
|
||||
return app.fetch(args.request);
|
||||
};
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import path from "node:path";
|
||||
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 { config } from "core";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
|
||||
let app: App;
|
||||
|
||||
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
|
||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||
|
||||
if (!app) {
|
||||
registerLocalMediaAdapter();
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
@@ -29,7 +30,6 @@ export function serve({
|
||||
distPath,
|
||||
connection,
|
||||
initialConfig,
|
||||
plugins,
|
||||
options,
|
||||
port = config.server.default_port,
|
||||
onBuilt,
|
||||
@@ -43,7 +43,6 @@ export function serve({
|
||||
const app = await createApp({
|
||||
connection,
|
||||
initialConfig,
|
||||
plugins,
|
||||
options,
|
||||
onBuilt,
|
||||
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;
|
||||
}
|
||||
}
|
||||
+49
-8
@@ -1,6 +1,47 @@
|
||||
import { isDebug } from "core";
|
||||
import type { FileBody, StorageAdapter } from "../Storage";
|
||||
import { guessMimeType } from "../mime-types";
|
||||
import { registries } from "bknd";
|
||||
import { isDebug } from "bknd/core";
|
||||
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
|
||||
@@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter {
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return undefined;
|
||||
return makeSchema();
|
||||
}
|
||||
|
||||
async putObject(key: string, body: FileBody) {
|
||||
@@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter {
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
let object: R2ObjectBody | null;
|
||||
const responseHeaders = new Headers({
|
||||
"Accept-Ranges": "bytes"
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Type": guess(key)
|
||||
});
|
||||
|
||||
//console.log("getObject:headers", headersToObject(headers));
|
||||
@@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter {
|
||||
if (!metadata || Object.keys(metadata).length === 0) {
|
||||
// guessing is especially required for dev environment (miniflare)
|
||||
metadata = {
|
||||
contentType: guessMimeType(object.key)
|
||||
contentType: guess(object.key)
|
||||
};
|
||||
}
|
||||
//console.log("writeHttpMetadata", object.httpMetadata, metadata);
|
||||
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||
@@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter {
|
||||
}
|
||||
|
||||
return {
|
||||
type: String(head.httpMetadata?.contentType ?? "application/octet-stream"),
|
||||
type: String(head.httpMetadata?.contentType ?? guess(key)),
|
||||
size: head.size
|
||||
};
|
||||
}
|
||||
@@ -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 { 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 { getDurable } from "./modes/durable";
|
||||
import { getFresh, getWarm } from "./modes/fresh";
|
||||
|
||||
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
|
||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
|
||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||
bindings?: (env: Env) => {
|
||||
bindings?: (args: Context<Env>) => {
|
||||
kv?: KVNamespace;
|
||||
dobj?: DurableObjectNamespace;
|
||||
db?: D1Database;
|
||||
};
|
||||
static?: "kv" | "assets";
|
||||
key?: string;
|
||||
keepAliveSeconds?: number;
|
||||
forceHttps?: boolean;
|
||||
@@ -21,28 +26,65 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
|
||||
html?: string;
|
||||
};
|
||||
|
||||
export type Context = {
|
||||
export type Context<Env = any> = {
|
||||
request: Request;
|
||||
env: any;
|
||||
env: Env;
|
||||
ctx: ExecutionContext;
|
||||
};
|
||||
|
||||
export function serve(config: CloudflareBkndConfig) {
|
||||
return {
|
||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||
const url = new URL(request.url);
|
||||
const manifest = config.manifest;
|
||||
let media_registered: boolean = false;
|
||||
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
|
||||
if (!media_registered) {
|
||||
registerMedia(context.env as any);
|
||||
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 assetManifest = JSON.parse(manifest);
|
||||
const assetManifest = JSON.parse(config.manifest);
|
||||
if (pathname && pathname in assetManifest) {
|
||||
const hono = new Hono();
|
||||
|
||||
hono.all("*", async (c, next) => {
|
||||
const res = await serveStatic({
|
||||
path: `./${pathname}`,
|
||||
manifest
|
||||
manifest: config.manifest!
|
||||
})(c as any, next);
|
||||
if (res instanceof Response) {
|
||||
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 mode = config.mode ?? "warm";
|
||||
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||
|
||||
export * from "./cloudflare-workers.adapter";
|
||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||
export { getCached } from "./modes/cached";
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRuntimeApp } from "adapter";
|
||||
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)!;
|
||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||
const key = config.key ?? "app";
|
||||
@@ -16,7 +16,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
|
||||
|
||||
const app = await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
...makeCfConfig(config, { env, ctx, ...args }),
|
||||
initialConfig,
|
||||
onBuilt: async (app) => {
|
||||
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 }
|
||||
},
|
||||
env
|
||||
{ env, ctx, ...args }
|
||||
);
|
||||
|
||||
if (!cachedConfig) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
import { createRuntimeApp } from "adapter";
|
||||
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
|
||||
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) {
|
||||
const { dobj } = config.bindings?.(ctx.env)!;
|
||||
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
||||
const id = dobj.idFromName(key);
|
||||
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, {
|
||||
config: create_config,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { createRuntimeApp } from "adapter";
|
||||
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(
|
||||
{
|
||||
...config,
|
||||
...makeCfConfig(config, ctx),
|
||||
adminOptions: config.html ? { html: config.html } : undefined
|
||||
},
|
||||
env
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+13
-49
@@ -1,59 +1,29 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { App, type CreateAppConfig, registries } from "bknd";
|
||||
import { config as $config } from "core";
|
||||
import { App, type CreateAppConfig } from "bknd";
|
||||
import { config as $config } from "bknd/core";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||
|
||||
export type BkndConfig<Env = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||
onBuilt?: (app: App) => Promise<void>;
|
||||
beforeBuild?: (app: App) => Promise<void>;
|
||||
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;
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
export function registerLocalMediaAdapter() {
|
||||
registries.media.register("local", StorageLocalAdapter);
|
||||
}
|
||||
|
||||
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
|
||||
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
|
||||
let additionalConfig: CreateAppConfig = {};
|
||||
if ("app" in config && config.app) {
|
||||
if (typeof config.app === "function") {
|
||||
if (!env) {
|
||||
throw new Error("env is required when config.app is a function");
|
||||
if (!args) {
|
||||
throw new Error("args is required when config.app is a function");
|
||||
}
|
||||
additionalConfig = config.app(env);
|
||||
additionalConfig = config.app(args);
|
||||
} else {
|
||||
additionalConfig = config.app;
|
||||
}
|
||||
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
|
||||
return { ...config, ...additionalConfig };
|
||||
}
|
||||
|
||||
export async function createFrameworkApp<Env = any>(
|
||||
export async function createFrameworkApp<Args = any>(
|
||||
config: FrameworkBkndConfig,
|
||||
env?: Env
|
||||
args?: Args
|
||||
): Promise<App> {
|
||||
const app = App.create(makeConfig(config, env));
|
||||
const app = App.create(makeConfig(config, args));
|
||||
|
||||
if (config.onBuilt) {
|
||||
app.emgr.onEvent(
|
||||
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
|
||||
export async function createRuntimeApp<Env = any>(
|
||||
{
|
||||
serveStatic,
|
||||
registerLocalMedia,
|
||||
adminOptions,
|
||||
...config
|
||||
}: RuntimeBkndConfig & {
|
||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||
registerLocalMedia?: boolean;
|
||||
adminOptions?: AdminControllerOptions | false;
|
||||
},
|
||||
env?: Env
|
||||
): Promise<App> {
|
||||
if (registerLocalMedia) {
|
||||
registerLocalMediaAdapter();
|
||||
}
|
||||
|
||||
const app = App.create(makeConfig(config, env));
|
||||
|
||||
app.emgr.onEvent(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Api, type App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
|
||||
import { nodeRequestToRequest } from "adapter/utils";
|
||||
import type { App } from "bknd";
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||
import { Api } from "bknd/client";
|
||||
|
||||
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
||||
cleanSearch?: string[];
|
||||
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
|
||||
}
|
||||
|
||||
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
||||
return (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||
return handler({ ...ctx, api: createApi(ctx) });
|
||||
return async (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||
const api = createApi(ctx);
|
||||
await api.verifyAuth();
|
||||
return handler({ ...ctx, api });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export * from "./node.adapter";
|
||||
export {
|
||||
StorageLocalAdapter,
|
||||
type LocalAdapterConfig
|
||||
import { registries } from "bknd";
|
||||
import {
|
||||
type LocalAdapterConfig,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from "node:path";
|
||||
import { serve as honoServe } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { registerLocalMediaAdapter } from "adapter/node/index";
|
||||
import type { App } from "bknd";
|
||||
import { config as $config } from "core";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { config as $config } from "bknd/core";
|
||||
|
||||
export type NodeBkndConfig = RuntimeBkndConfig & {
|
||||
port?: number;
|
||||
@@ -37,9 +38,9 @@ export function serve({
|
||||
hostname,
|
||||
fetch: async (req: Request) => {
|
||||
if (!app) {
|
||||
registerLocalMediaAdapter();
|
||||
app = await createRuntimeApp({
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
serveStatic: serveStatic({ root })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useAuth } from "bknd/client";
|
||||
import type { BkndAdminProps } from "bknd/ui";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
|
||||
export function adminPage(props?: BkndAdminProps) {
|
||||
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
||||
return () => {
|
||||
const auth = useAuth();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Admin {...props} />
|
||||
<Admin withProvider={{ user: auth.user }} {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
||||
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;
|
||||
export function serve(config: RemixBkndConfig = {}) {
|
||||
return async (args: { request: Request }) => {
|
||||
if (!app) {
|
||||
app = await createFrameworkApp(config);
|
||||
let building: boolean = false;
|
||||
|
||||
export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
||||
import type { App } from "bknd";
|
||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { devServerConfig } from "./dev-server-config";
|
||||
|
||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
||||
}
|
||||
|
||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||
registerLocalMediaAdapter();
|
||||
return await createRuntimeApp(
|
||||
{
|
||||
...config,
|
||||
registerLocalMedia: true,
|
||||
adminOptions:
|
||||
config.setAdminHtml === false
|
||||
? undefined
|
||||
|
||||
@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
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) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
@@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||
}
|
||||
|
||||
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) {
|
||||
await this.options.onTokenUpdate?.(res.body.token);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export class AuthController extends Controller {
|
||||
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>) {
|
||||
const actions = strategy.getActions?.();
|
||||
if (!actions) {
|
||||
@@ -95,8 +104,10 @@ export class AuthController extends Controller {
|
||||
}
|
||||
|
||||
hono.get("/me", auth(), async (c) => {
|
||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
||||
return c.json({ user: this.auth.authenticator.getUser() });
|
||||
const claims = c.get("auth")?.user;
|
||||
if (claims) {
|
||||
const { data: user } = await this.userRepo.findId(claims.id);
|
||||
return c.json({ user });
|
||||
}
|
||||
|
||||
return c.json({ user: null }, 403);
|
||||
@@ -121,6 +132,6 @@ export class AuthController extends Controller {
|
||||
return c.json({ strategies, basepath });
|
||||
});
|
||||
|
||||
return hono;
|
||||
return hono.all("*", (c) => c.notFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,17 +106,15 @@ export type AuthUserResolver = (
|
||||
identifier: string,
|
||||
profile: ProfileExchange
|
||||
) => Promise<SafeUser | undefined>;
|
||||
type AuthClaims = SafeUser & {
|
||||
iat: number;
|
||||
iss?: string;
|
||||
exp?: number;
|
||||
};
|
||||
|
||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||
private readonly strategies: Strategies;
|
||||
private readonly config: AuthConfig;
|
||||
private _claims:
|
||||
| undefined
|
||||
| (SafeUser & {
|
||||
iat: number;
|
||||
iss?: string;
|
||||
exp?: number;
|
||||
});
|
||||
private readonly userResolver: AuthUserResolver;
|
||||
|
||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
||||
@@ -148,21 +146,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
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<
|
||||
StrategyName extends keyof Strategies,
|
||||
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");
|
||||
}
|
||||
|
||||
async verify(jwt: string): Promise<boolean> {
|
||||
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
||||
try {
|
||||
const payload = await verify(
|
||||
jwt,
|
||||
@@ -221,14 +204,10 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
this._claims = payload as any;
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.resetUser();
|
||||
//console.error(e);
|
||||
}
|
||||
return payload as any;
|
||||
} catch (e) {}
|
||||
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
|
||||
private get cookieOptions(): CookieOptions {
|
||||
@@ -258,8 +237,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
async requestCookieRefresh(c: Context) {
|
||||
if (this.config.cookie.renew) {
|
||||
async requestCookieRefresh(c: Context<ServerEnv>) {
|
||||
if (this.config.cookie.renew && c.get("auth")?.user) {
|
||||
const token = await this.getAuthCookie(c);
|
||||
if (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);
|
||||
}
|
||||
|
||||
async logout(c: Context) {
|
||||
async logout(c: Context<ServerEnv>) {
|
||||
c.set("auth", undefined);
|
||||
|
||||
const cookie = await this.getAuthCookie(c);
|
||||
if (cookie) {
|
||||
await this.deleteAuthCookie(c);
|
||||
await addFlashMessage(c, "Signed out", "info");
|
||||
}
|
||||
this.resetUser();
|
||||
}
|
||||
|
||||
// @todo: move this to a server helper
|
||||
@@ -299,8 +279,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
}
|
||||
|
||||
private getSuccessPath(c: Context) {
|
||||
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
|
||||
private getSafeUrl(c: Context, path: string) {
|
||||
const p = path.replace(/\/+$/, "/");
|
||||
|
||||
// nextjs doesn't support non-fq urls
|
||||
// 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) {
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data);
|
||||
}
|
||||
|
||||
const successUrl = this.getSuccessPath(c);
|
||||
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
|
||||
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
||||
//console.log("auth respond", { redirect, successUrl, successPath });
|
||||
|
||||
if ("token" in data) {
|
||||
await this.setAuthCookie(c, data.token);
|
||||
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data);
|
||||
}
|
||||
|
||||
// can't navigate to "/" – doesn't work on nextjs
|
||||
//console.log("auth success, redirecting to", successUrl);
|
||||
return c.redirect(successUrl);
|
||||
}
|
||||
|
||||
if (this.isJsonRequest(c)) {
|
||||
return c.json(data, 400);
|
||||
}
|
||||
|
||||
let message = "An error occured";
|
||||
if (data instanceof Exception) {
|
||||
message = data.message;
|
||||
@@ -348,8 +333,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await this.verify(token);
|
||||
return this.getUser();
|
||||
return await this.verify(token);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Authenticator, Strategy } from "auth";
|
||||
import { isDebug, tbValidator as tb } from "core";
|
||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||
import { hash } from "core/utils";
|
||||
import { type Context, Hono } from "hono";
|
||||
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
|
||||
const hono = new Hono();
|
||||
|
||||
return hono
|
||||
.post("/login", async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
.post(
|
||||
"/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 {
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
||||
try {
|
||||
const payload = await this.login(body);
|
||||
const data = await authenticator.resolve(
|
||||
"login",
|
||||
this,
|
||||
payload.password,
|
||||
payload
|
||||
);
|
||||
|
||||
return await authenticator.respond(c, data);
|
||||
} catch (e) {
|
||||
return await authenticator.respond(c, e);
|
||||
return await authenticator.respond(c, data, redirect);
|
||||
} catch (e) {
|
||||
return await authenticator.respond(c, e);
|
||||
}
|
||||
}
|
||||
})
|
||||
.post("/register", async (c) => {
|
||||
const body = await authenticator.getBody(c);
|
||||
)
|
||||
.post(
|
||||
"/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 data = await authenticator.resolve("register", this, payload.password, payload);
|
||||
const payload = await this.register(body);
|
||||
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 {
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Exception, Permission } from "core";
|
||||
import { objectTransform } from "core/utils";
|
||||
import type { Context } from "hono";
|
||||
import type { ServerEnv } from "modules/Module";
|
||||
import { Role } from "./Role";
|
||||
|
||||
export type GuardUserContext = {
|
||||
role: string | null | undefined;
|
||||
role?: string | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type GuardConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||
|
||||
const debug = false;
|
||||
|
||||
export class Guard {
|
||||
permissions: Permission[];
|
||||
user?: GuardUserContext;
|
||||
roles?: Role[];
|
||||
config?: GuardConfig;
|
||||
|
||||
@@ -89,24 +91,19 @@ export class Guard {
|
||||
return this;
|
||||
}
|
||||
|
||||
setUserContext(user: GuardUserContext | undefined) {
|
||||
this.user = user;
|
||||
return this;
|
||||
}
|
||||
|
||||
getUserRole(): Role | undefined {
|
||||
if (this.user && typeof this.user.role === "string") {
|
||||
const role = this.roles?.find((role) => role.name === this.user?.role);
|
||||
getUserRole(user?: GuardUserContext): Role | undefined {
|
||||
if (user && typeof user.role === "string") {
|
||||
const role = this.roles?.find((role) => role.name === user?.role);
|
||||
if (role) {
|
||||
debug && console.log("guard: role found", [this.user.role]);
|
||||
debug && console.log("guard: role found", [user.role]);
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
debug &&
|
||||
console.log("guard: role not found", {
|
||||
user: this.user,
|
||||
role: this.user?.role
|
||||
user: user,
|
||||
role: user?.role
|
||||
});
|
||||
return this.getDefaultRole();
|
||||
}
|
||||
@@ -119,9 +116,9 @@ export class Guard {
|
||||
return this.config?.enabled === true;
|
||||
}
|
||||
|
||||
hasPermission(permission: Permission): boolean;
|
||||
hasPermission(name: string): boolean;
|
||||
hasPermission(permissionOrName: Permission | string): boolean {
|
||||
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
|
||||
hasPermission(name: string, user?: GuardUserContext): boolean;
|
||||
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
||||
if (!this.isEnabled()) {
|
||||
//console.log("guard not enabled, allowing");
|
||||
return true;
|
||||
@@ -133,7 +130,7 @@ export class Guard {
|
||||
throw new Error(`Permission ${name} does not exist`);
|
||||
}
|
||||
|
||||
const role = this.getUserRole();
|
||||
const role = this.getUserRole(user);
|
||||
|
||||
if (!role) {
|
||||
debug && console.log("guard: role not found, denying");
|
||||
@@ -156,12 +153,13 @@ export class Guard {
|
||||
return !!rolePermission;
|
||||
}
|
||||
|
||||
granted(permission: Permission | string): boolean {
|
||||
return this.hasPermission(permission as any);
|
||||
granted(permission: Permission | string, c?: GuardContext): boolean {
|
||||
const user = c && "get" in c ? c.get("auth")?.user : c;
|
||||
return this.hasPermission(permission as any, user);
|
||||
}
|
||||
|
||||
throwUnlessGranted(permission: Permission | string) {
|
||||
if (!this.granted(permission)) {
|
||||
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
|
||||
if (!this.granted(permission, c)) {
|
||||
throw new Exception(
|
||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||
403
|
||||
|
||||
+33
-23
@@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) {
|
||||
}
|
||||
|
||||
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;
|
||||
if (!skip) return false;
|
||||
@@ -18,7 +23,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
||||
const path = getPath(req);
|
||||
const result = skip.some((s) => patternMatch(path, s));
|
||||
|
||||
c.set("auth_skip", result);
|
||||
authCtx.skip = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -26,29 +31,31 @@ export const auth = (options?: {
|
||||
skip?: (string | RegExp)[];
|
||||
}) =>
|
||||
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 guard = app?.modules.ctx().guard;
|
||||
const authCtx = c.get("auth")!;
|
||||
const authenticator = app?.module.auth.authenticator;
|
||||
|
||||
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
||||
|
||||
// make sure to only register once
|
||||
if (c.get("auth_registered")) {
|
||||
if (authCtx.registered) {
|
||||
skipped = true;
|
||||
console.warn(`auth middleware already registered for ${getPath(c)}`);
|
||||
} else {
|
||||
c.set("auth_registered", true);
|
||||
authCtx.registered = true;
|
||||
|
||||
if (!skipped) {
|
||||
const resolved = c.get("auth_resolved");
|
||||
if (!resolved) {
|
||||
if (!app?.module.auth.enabled) {
|
||||
guard?.setUserContext(undefined);
|
||||
} else {
|
||||
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
|
||||
c.set("auth_resolved", true);
|
||||
}
|
||||
}
|
||||
if (!skipped && !authCtx.resolved && app?.module.auth.enabled) {
|
||||
authCtx.user = await authenticator?.resolveAuthFromRequest(c);
|
||||
authCtx.resolved = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +67,9 @@ export const auth = (options?: {
|
||||
}
|
||||
|
||||
// release
|
||||
guard?.setUserContext(undefined);
|
||||
authenticator?.resetUser();
|
||||
c.set("auth_resolved", false);
|
||||
authCtx.skip = false;
|
||||
authCtx.resolved = false;
|
||||
authCtx.user = undefined;
|
||||
});
|
||||
|
||||
export const permission = (
|
||||
@@ -75,23 +82,26 @@ export const permission = (
|
||||
// @ts-ignore
|
||||
createMiddleware<ServerEnv>(async (c, next) => {
|
||||
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
|
||||
if (!c.get("auth_registered") || !app) {
|
||||
if (!authCtx.registered || !app) {
|
||||
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||
if (app?.module.auth.enabled) {
|
||||
throw new Error(msg);
|
||||
} else {
|
||||
console.warn(msg);
|
||||
}
|
||||
} else if (!c.get("auth_skip")) {
|
||||
} else if (!authCtx.skip) {
|
||||
const guard = app.modules.ctx().guard;
|
||||
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||
|
||||
if (options?.onGranted || options?.onDenied) {
|
||||
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);
|
||||
} else {
|
||||
returned = await options?.onDenied?.(c);
|
||||
@@ -100,7 +110,7 @@ export const permission = (
|
||||
return returned;
|
||||
}
|
||||
} else {
|
||||
permissions.some((p) => guard.throwUnlessGranted(p));
|
||||
permissions.some((p) => guard.throwUnlessGranted(p, c));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./create";
|
||||
@@ -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;
|
||||
@@ -1,20 +1,44 @@
|
||||
import path from "node:path";
|
||||
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 { Argument } from "commander";
|
||||
import { showRoutes } from "hono/dev";
|
||||
import type { CliCommand } from "../types";
|
||||
|
||||
export const debug: CliCommand = (program) => {
|
||||
program
|
||||
.command("debug")
|
||||
.description("debug path resolution")
|
||||
.action(() => {
|
||||
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")
|
||||
});
|
||||
});
|
||||
.description("debug bknd")
|
||||
.addArgument(new Argument("<subject>", "subject to debug").choices(Object.keys(subjects)))
|
||||
.action(action);
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export { schema } from "./schema";
|
||||
export { run } from "./run";
|
||||
export { debug } from "./debug";
|
||||
export { user } from "./user";
|
||||
export { create } from "./create";
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function attachServeStatic(app: any, platform: Platform) {
|
||||
|
||||
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||
const port = options.port;
|
||||
console.log(`(using ${server} serve)`);
|
||||
console.log(`Using ${server} serve`);
|
||||
|
||||
switch (server) {
|
||||
case "node": {
|
||||
@@ -54,7 +54,7 @@ export async function startServer(server: Platform, app: any, options: { port: n
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(`Server listening on ${url}`);
|
||||
console.info("Server listening on", url);
|
||||
await open(url);
|
||||
}
|
||||
|
||||
@@ -76,3 +76,9 @@ export async function getConfigPath(filePath?: string) {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export function getConnectionCredentialsFromEnv() {
|
||||
const dbUrl = process.env.DB_URL;
|
||||
const dbToken = process.env.DB_TOKEN;
|
||||
return dbUrl ? { url: dbUrl, authToken: dbToken } : undefined;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,20 @@ import { App, type CreateAppConfig } from "App";
|
||||
import { StorageLocalAdapter } from "adapter/node";
|
||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||
import { Option } from "commander";
|
||||
import { config } from "core";
|
||||
import { colorizeConsole, config } from "core";
|
||||
import dotenv from "dotenv";
|
||||
import { registries } from "modules/registries";
|
||||
import c from "picocolors";
|
||||
import {
|
||||
PLATFORMS,
|
||||
type Platform,
|
||||
attachServeStatic,
|
||||
getConfigPath,
|
||||
getConnectionCredentialsFromEnv,
|
||||
startServer
|
||||
} from "./platform";
|
||||
|
||||
dotenv.config();
|
||||
const isBun = typeof Bun !== "undefined";
|
||||
|
||||
export const run: CliCommand = (program) => {
|
||||
@@ -24,6 +28,13 @@ export const run: CliCommand = (program) => {
|
||||
.default(config.server.default_port)
|
||||
.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("--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: {
|
||||
port: number;
|
||||
memory?: boolean;
|
||||
config?: string;
|
||||
dbUrl?: string;
|
||||
dbToken?: string;
|
||||
server: Platform;
|
||||
}) {
|
||||
colorizeConsole(console);
|
||||
const configFilePath = await getConfigPath(options.config);
|
||||
|
||||
let app: App;
|
||||
if (options.dbUrl || !configFilePath) {
|
||||
let app: App | undefined = undefined;
|
||||
if (options.dbUrl) {
|
||||
console.info("Using connection from", c.cyan("--db-url"));
|
||||
const connection = options.dbUrl
|
||||
? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } }
|
||||
? { url: options.dbUrl, authToken: options.dbToken }
|
||||
: undefined;
|
||||
app = await makeApp({ connection, server: { platform: options.server } });
|
||||
} else {
|
||||
console.log("Using config from:", configFilePath);
|
||||
} else if (configFilePath) {
|
||||
console.info("Using config from", c.cyan(configFilePath));
|
||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||
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 });
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from "commander";
|
||||
import color from "picocolors";
|
||||
import * as commands from "./commands";
|
||||
import { getVersion } from "./utils/sys";
|
||||
const program = new Command();
|
||||
|
||||
export async function main() {
|
||||
const version = await getVersion();
|
||||
program
|
||||
.name("bknd")
|
||||
.description("bknd cli")
|
||||
.version(await getVersion());
|
||||
.description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`)))
|
||||
.version(version);
|
||||
|
||||
// register commands
|
||||
for (const command of Object.values(commands)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync, exec as nodeExec } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import url from "node:url";
|
||||
@@ -16,9 +17,9 @@ export function getRelativeDistPath() {
|
||||
return path.relative(process.cwd(), getDistPath());
|
||||
}
|
||||
|
||||
export async function getVersion() {
|
||||
export async function getVersion(_path: string = "") {
|
||||
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");
|
||||
if (pkg) {
|
||||
return JSON.parse(pkg).version ?? "preview";
|
||||
@@ -38,3 +39,35 @@ export async function fileExists(filePath: string) {
|
||||
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);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,17 @@ export class EventListener<E extends Event = Event> {
|
||||
event: EventClass;
|
||||
handler: ListenerHandler<E>;
|
||||
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.handler = handler;
|
||||
this.mode = mode;
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type RegisterListenerConfig =
|
||||
| {
|
||||
mode?: ListenerMode;
|
||||
once?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export interface EmitsEvents {
|
||||
@@ -124,6 +125,15 @@ export class EventManager<
|
||||
addListener(listener: EventListener) {
|
||||
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);
|
||||
return this;
|
||||
}
|
||||
@@ -140,6 +150,9 @@ export class EventManager<
|
||||
if (config.once) {
|
||||
listener.once = true;
|
||||
}
|
||||
if (config.id) {
|
||||
listener.id = `${event.slug}-${config.id}`;
|
||||
}
|
||||
this.addListener(listener as any);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export {
|
||||
} from "./object/query/query";
|
||||
export { Registry, type Constructor } from "./registry/Registry";
|
||||
|
||||
export * from "./console";
|
||||
|
||||
// compatibility
|
||||
export type Middleware = MiddlewareHandler<any, any, any>;
|
||||
export interface ClassController {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
|
||||
import { get, has, omit, set } from "lodash-es";
|
||||
import {
|
||||
Default,
|
||||
type Static,
|
||||
type TObject,
|
||||
getFullPathKeys,
|
||||
mark,
|
||||
mergeObjectWith,
|
||||
parse,
|
||||
stripMark
|
||||
} from "../utils";
|
||||
@@ -33,7 +33,7 @@ export class SchemaObject<Schema extends TObject> {
|
||||
) {
|
||||
this._default = Default(_schema, {} as any) as any;
|
||||
this._value = initial
|
||||
? parse(_schema, cloneDeep(initial as any), {
|
||||
? parse(_schema, structuredClone(initial as any), {
|
||||
forceParse: this.isForceParse(),
|
||||
skipMark: this.isForceParse()
|
||||
})
|
||||
@@ -64,8 +64,12 @@ export class SchemaObject<Schema extends TObject> {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
clone() {
|
||||
return structuredClone(this._config);
|
||||
}
|
||||
|
||||
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
|
||||
const valid = parse(this._schema, cloneDeep(config) as any, {
|
||||
const valid = parse(this._schema, structuredClone(config) as any, {
|
||||
forceParse: true,
|
||||
skipMark: this.isForceParse()
|
||||
});
|
||||
@@ -114,7 +118,7 @@ export class SchemaObject<Schema extends TObject> {
|
||||
}
|
||||
|
||||
async patch(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
const current = cloneDeep(this._config);
|
||||
const current = this.clone();
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
@@ -122,11 +126,13 @@ export class SchemaObject<Schema extends TObject> {
|
||||
|
||||
// overwrite arrays and primitives, only deep merge objects
|
||||
// @ts-ignore
|
||||
const config = mergeWith(current, partial, (objValue, srcValue) => {
|
||||
//console.log("---alt:new", _jsonp(mergeObject(current, partial)));
|
||||
const config = mergeObjectWith(current, partial, (objValue, srcValue) => {
|
||||
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
//console.log("---new", _jsonp(config));
|
||||
|
||||
//console.log("overwritePaths", this.options?.overwritePaths);
|
||||
if (this.options?.overwritePaths) {
|
||||
@@ -164,14 +170,14 @@ export class SchemaObject<Schema extends TObject> {
|
||||
}
|
||||
}
|
||||
|
||||
//console.log("patch", { path, value, partial, config, current });
|
||||
//console.log("patch", _jsonp({ path, value, partial, config, current }));
|
||||
|
||||
const newConfig = await this.set(config);
|
||||
return [partial, newConfig];
|
||||
}
|
||||
|
||||
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
||||
const current = cloneDeep(this._config);
|
||||
const current = this.clone();
|
||||
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||
|
||||
this.throwIfRestricted(partial);
|
||||
@@ -192,7 +198,7 @@ export class SchemaObject<Schema extends TObject> {
|
||||
if (p.length > 1) {
|
||||
const parent = p.slice(0, -1).join(".");
|
||||
if (!has(this._config, parent)) {
|
||||
console.log("parent", parent, JSON.stringify(this._config, null, 2));
|
||||
//console.log("parent", parent, JSON.stringify(this._config, null, 2));
|
||||
throw new Error(`Parent path "${parent}" does not exist`);
|
||||
}
|
||||
}
|
||||
@@ -207,7 +213,7 @@ export class SchemaObject<Schema extends TObject> {
|
||||
throw new Error(`Path "${path}" does not exist`);
|
||||
}
|
||||
|
||||
const current = cloneDeep(this._config);
|
||||
const current = this.clone();
|
||||
const removed = get(current, path) as Partial<Static<Schema>>;
|
||||
const config = omit(current, path);
|
||||
const newConfig = await this.set(config);
|
||||
|
||||
@@ -4,6 +4,28 @@ export function _jsonp(obj: any, space = 2): string {
|
||||
return JSON.stringify(obj, null, space);
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return Object.prototype.toString.call(value) === "[object Object]";
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
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 {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
try {
|
||||
@@ -97,15 +119,6 @@ export function objectEach<T extends Record<string, any>, U>(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple object check.
|
||||
* @param item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isObject(item) {
|
||||
return item && typeof item === "object" && !Array.isArray(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects.
|
||||
* @param target
|
||||
@@ -197,3 +210,152 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
|
||||
return acc;
|
||||
}, {} as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lodash's merge implementation caused issues in Next.js environments
|
||||
* From: https://thescottyjam.github.io/snap.js/#!/nolodash/merge
|
||||
* NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
|
||||
* @param object
|
||||
* @param sources
|
||||
*/
|
||||
export function mergeObject(object, ...sources) {
|
||||
for (const source of sources) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// These checks are a week attempt at mimicking the various edge-case behaviors
|
||||
// that Lodash's `_.merge()` exhibits. Feel free to simplify and
|
||||
// remove checks that you don't need.
|
||||
if (!isPlainObject(value) && !Array.isArray(value)) {
|
||||
object[key] = value;
|
||||
} else if (Array.isArray(value) && !Array.isArray(object[key])) {
|
||||
object[key] = value;
|
||||
} else if (!isObject(object[key])) {
|
||||
object[key] = value;
|
||||
} else {
|
||||
mergeObject(object[key], value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lodash's mergeWith implementation caused issues in Next.js environments
|
||||
* From: https://thescottyjam.github.io/snap.js/#!/nolodash/mergeWith
|
||||
* NOTE: This mutates `object`. It also may mutate anything that gets attached to `object` during the merge.
|
||||
* @param object
|
||||
* @param sources
|
||||
* @param customizer
|
||||
*/
|
||||
export function mergeObjectWith(object, source, customizer) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
const mergedValue = customizer(object[key], value, key, object, source);
|
||||
if (mergedValue !== undefined) {
|
||||
object[key] = mergedValue;
|
||||
continue;
|
||||
}
|
||||
// Otherwise, fall back to default behavior
|
||||
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// These checks are a week attempt at mimicking the various edge-case behaviors
|
||||
// that Lodash's `_.merge()` exhibits. Feel free to simplify and
|
||||
// remove checks that you don't need.
|
||||
if (!isPlainObject(value) && !Array.isArray(value)) {
|
||||
object[key] = value;
|
||||
} else if (Array.isArray(value) && !Array.isArray(object[key])) {
|
||||
object[key] = value;
|
||||
} else if (!isObject(object[key])) {
|
||||
object[key] = value;
|
||||
} else {
|
||||
mergeObjectWith(object[key], value, customizer);
|
||||
}
|
||||
}
|
||||
|
||||
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(".")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
if (!headers) return {};
|
||||
return { ...Object.fromEntries(headers.entries()) };
|
||||
@@ -82,3 +86,259 @@ export function decodeSearch(str) {
|
||||
|
||||
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 (100–199)
|
||||
CONTINUE = 100,
|
||||
SWITCHING_PROTOCOLS = 101,
|
||||
PROCESSING = 102,
|
||||
EARLY_HINTS = 103,
|
||||
|
||||
// Successful responses (200–299)
|
||||
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 (300–399)
|
||||
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 (400–499)
|
||||
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 (500–599)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -81,9 +81,12 @@ export function identifierToHumanReadable(str: string) {
|
||||
case "SCREAMING_SNAKE_CASE":
|
||||
return snakeToPascalWithSpaces(str.toLowerCase());
|
||||
case "unknown":
|
||||
return str;
|
||||
return ucFirst(str);
|
||||
}
|
||||
}
|
||||
export function autoFormatString(str: string) {
|
||||
return identifierToHumanReadable(str);
|
||||
}
|
||||
|
||||
export function kebabToPascalWithSpaces(str: string): string {
|
||||
return str.split("-").map(ucFirst).join(" ");
|
||||
|
||||
@@ -42,3 +42,21 @@ export function enableConsoleLog() {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type ValueErrorIterator
|
||||
} from "@sinclair/typebox/errors";
|
||||
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
|
||||
export type RecursivePartial<T> = {
|
||||
[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) {
|
||||
const newObj = cloneDeep(obj);
|
||||
const newObj = structuredClone(obj);
|
||||
mark(newObj, false);
|
||||
return newObj as O;
|
||||
}
|
||||
|
||||
+13
-4
@@ -13,7 +13,15 @@ import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
||||
|
||||
export class AppData extends Module<typeof dataConfigSchema> {
|
||||
override async build() {
|
||||
const entities = transformObject(this.config.entities ?? {}, (entityConfig, name) => {
|
||||
const {
|
||||
entities: _entities = {},
|
||||
relations: _relations = {},
|
||||
indices: _indices = {}
|
||||
} = this.config;
|
||||
|
||||
this.ctx.logger.context("AppData").log("building with entities", Object.keys(_entities));
|
||||
|
||||
const entities = transformObject(_entities, (entityConfig, name) => {
|
||||
return constructEntity(name, entityConfig);
|
||||
});
|
||||
|
||||
@@ -21,14 +29,14 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
const name = typeof _e === "string" ? _e : _e.name;
|
||||
const entity = entities[name];
|
||||
if (entity) return entity;
|
||||
throw new Error(`Entity "${name}" not found`);
|
||||
throw new Error(`[AppData] Entity "${name}" not found`);
|
||||
};
|
||||
|
||||
const relations = transformObject(this.config.relations ?? {}, (relation) =>
|
||||
const relations = transformObject(_relations, (relation) =>
|
||||
constructRelation(relation, _entity)
|
||||
);
|
||||
|
||||
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
|
||||
const indices = transformObject(_indices, (index, name) => {
|
||||
const entity = _entity(index.entity)!;
|
||||
const fields = index.fields.map((f) => entity.field(f)!);
|
||||
return new EntityIndex(entity, fields, index.unique, name);
|
||||
@@ -52,6 +60,7 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
||||
);
|
||||
this.ctx.guard.registerPermissions(Object.values(DataPermissions));
|
||||
|
||||
this.ctx.logger.clear();
|
||||
this.setBuilt();
|
||||
}
|
||||
|
||||
|
||||
+14
-11
@@ -1,10 +1,10 @@
|
||||
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";
|
||||
|
||||
export type DataApiOptions = BaseModuleApiOptions & {
|
||||
queryLengthLimit: number;
|
||||
defaultQuery: Partial<RepoQuery>;
|
||||
defaultQuery: Partial<RepoQueryIn>;
|
||||
};
|
||||
|
||||
export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
@@ -23,7 +23,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
id: PrimaryFieldType,
|
||||
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>(
|
||||
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||
|
||||
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) {
|
||||
return req;
|
||||
}
|
||||
|
||||
return this.post<T>([entity as any, "query"], input);
|
||||
return this.post<T>(["entity", entity as any, "query"], input);
|
||||
}
|
||||
|
||||
readManyByReference<
|
||||
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
Data = R extends keyof DB ? DB[R] : EntityData
|
||||
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||
[entity as any, id, reference],
|
||||
["entity", entity as any, id, reference],
|
||||
query ?? this.options.defaultQuery
|
||||
);
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
entity: E,
|
||||
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>(
|
||||
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
||||
id: PrimaryFieldType,
|
||||
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>(
|
||||
entity: E,
|
||||
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 }>>(
|
||||
[entity as any, "fn", "count"],
|
||||
["entity", entity as any, "fn", "count"],
|
||||
where
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,44 +109,7 @@ export class DataController extends Controller {
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 = 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
|
||||
* Schema endpoints
|
||||
*/
|
||||
hono
|
||||
// read entity schema
|
||||
@@ -181,7 +144,7 @@ export class DataController extends Controller {
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity, context } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
console.warn("not found:", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const _entity = this.em.entity(entity);
|
||||
@@ -197,7 +160,92 @@ export class DataController extends Controller {
|
||||
...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
|
||||
.get(
|
||||
"/:entity",
|
||||
@@ -208,7 +256,7 @@ export class DataController extends Controller {
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
console.log("not found", entity, definedEntities);
|
||||
console.warn("not found:", entity, definedEntities);
|
||||
return c.notFound();
|
||||
}
|
||||
const options = c.req.valid("query") as RepoQuery;
|
||||
@@ -327,12 +375,9 @@ export class DataController extends Controller {
|
||||
// delete one
|
||||
.delete(
|
||||
"/:entity/:id",
|
||||
|
||||
permission(DataPermissions.entityDelete),
|
||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||
async (c) => {
|
||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
||||
|
||||
const { entity, id } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
@@ -350,14 +395,11 @@ export class DataController extends Controller {
|
||||
tb("param", Type.Object({ entity: Type.String() })),
|
||||
tb("json", querySchema.properties.where),
|
||||
async (c) => {
|
||||
//console.log("request", c.req.raw);
|
||||
const { entity } = c.req.param();
|
||||
if (!this.entityExists(entity)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const where = c.req.valid("json") as RepoQuery["where"];
|
||||
//console.log("where", where);
|
||||
|
||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||
|
||||
return c.json(this.mutatorResult(result));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
|
||||
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 { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
|
||||
import type { QB } from "./Connection";
|
||||
@@ -28,7 +28,7 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
|
||||
let client: Client;
|
||||
if ("url" in clientOrCredentials) {
|
||||
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||
let { url, authToken, protocol } = clientOrCredentials;
|
||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||
console.log("changing protocol to", protocol);
|
||||
@@ -36,11 +36,8 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
url = `${protocol}://${rest}`;
|
||||
}
|
||||
|
||||
//console.log("using", url, { protocol });
|
||||
|
||||
client = createClient({ url, authToken });
|
||||
} else {
|
||||
//console.log("-- client provided");
|
||||
client = clientOrCredentials;
|
||||
}
|
||||
|
||||
@@ -48,7 +45,6 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
// @ts-expect-error libsql has type issues
|
||||
dialect: new CustomLibsqlDialect({ client }),
|
||||
plugins
|
||||
//log: ["query"],
|
||||
});
|
||||
|
||||
super(kysely, {}, plugins);
|
||||
@@ -74,7 +70,6 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
}> {
|
||||
const stms: InStatement[] = queries.map((q) => {
|
||||
const compiled = q.compile();
|
||||
//console.log("compiled", compiled.sql, compiled.parameters);
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
args: compiled.parameters as any[]
|
||||
@@ -91,7 +86,6 @@ export class LibsqlConnection extends SqliteConnection {
|
||||
const rows = await kyselyPlugins.transformResultRows(r.rows);
|
||||
data.push(rows);
|
||||
}
|
||||
//console.log("data", data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
ExpressionBuilder,
|
||||
Kysely,
|
||||
SchemaMetadata,
|
||||
TableMetadata,
|
||||
TableMetadata
|
||||
} from "kysely";
|
||||
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
|
||||
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
|
||||
@@ -62,7 +62,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
seqno: number;
|
||||
cid: number;
|
||||
name: string;
|
||||
}>`pragma_index_info(${index})`.as("index_info"),
|
||||
}>`pragma_index_info(${index})`.as("index_info")
|
||||
)
|
||||
.select(["seqno", "cid", "name"])
|
||||
.orderBy("cid")
|
||||
@@ -74,8 +74,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
isUnique: isUnique,
|
||||
columns: columns.map((col) => ({
|
||||
name: col.name,
|
||||
order: col.seqno,
|
||||
})),
|
||||
order: col.seqno
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
}
|
||||
|
||||
async getTables(
|
||||
options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
|
||||
options: DatabaseMetadataOptions = { withInternalKyselyTables: false }
|
||||
): Promise<TableMetadata[]> {
|
||||
let query = this.#db
|
||||
.selectFrom("sqlite_master")
|
||||
@@ -99,7 +99,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
|
||||
if (!options.withInternalKyselyTables) {
|
||||
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) {
|
||||
@@ -112,7 +112,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
|
||||
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
|
||||
return {
|
||||
tables: await this.getTables(options),
|
||||
tables: await this.getTables(options)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
type: string;
|
||||
notnull: 0 | 1;
|
||||
dflt_value: any;
|
||||
}>`pragma_table_info(${table})`.as("table_info"),
|
||||
}>`pragma_table_info(${table})`.as("table_info")
|
||||
)
|
||||
.select(["name", "type", "notnull", "dflt_value"])
|
||||
.orderBy("cid")
|
||||
@@ -157,8 +157,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
})),
|
||||
comment: undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +165,13 @@ export class Entity<
|
||||
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[] {
|
||||
if (include_virtual) return this.fields;
|
||||
return this.fields.filter((f) => !f.isVirtual());
|
||||
|
||||
@@ -107,18 +107,24 @@ export class Mutator<
|
||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||
const entity = this.entity;
|
||||
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 {
|
||||
entity,
|
||||
sql,
|
||||
parameters: [...parameters],
|
||||
result: result,
|
||||
data
|
||||
};
|
||||
const data = this.em.hydrate(entity.name, result) as EntityData[];
|
||||
|
||||
return {
|
||||
entity,
|
||||
sql,
|
||||
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>> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||
import { $console } from "core";
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import { type SelectQueryBuilder, sql } from "kysely";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
@@ -73,7 +74,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
sort: entity.getDefaultSort(),
|
||||
select: entity.getSelect()
|
||||
};
|
||||
//console.log("validated", 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) {
|
||||
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;
|
||||
@@ -160,7 +162,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||
const entity = this.entity;
|
||||
const compiled = qb.compile();
|
||||
//console.log("performQuery", compiled.sql, compiled.parameters);
|
||||
//$console.log("performQuery", compiled.sql, compiled.parameters);
|
||||
|
||||
const start = performance.now();
|
||||
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,
|
||||
qb
|
||||
]);
|
||||
//console.log("result", { _count, _total });
|
||||
//$console.log("result", { _count, _total });
|
||||
|
||||
const time = Number.parseFloat((performance.now() - start).toFixed(2));
|
||||
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) {
|
||||
if (e instanceof Error) {
|
||||
console.error("[ERROR] Repository.performQuery", e.message);
|
||||
$console.error("[ERROR] Repository.performQuery", e.message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
@@ -253,7 +255,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
...config?.defaults
|
||||
};
|
||||
|
||||
/*console.log("build query options", {
|
||||
/*$console.log("build query options", {
|
||||
entity: entity.name,
|
||||
options,
|
||||
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][]>> {
|
||||
const { qb, options } = this.buildQuery(_options);
|
||||
//console.log("findMany:options", options);
|
||||
|
||||
await this.emgr.emit(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -427,9 +427,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
||||
qb = qb.limit(1);
|
||||
|
||||
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();
|
||||
//console.log("result", result);
|
||||
//$console.log("result", result);
|
||||
|
||||
return {
|
||||
sql: compiled.sql,
|
||||
|
||||
@@ -37,7 +37,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -18,6 +18,8 @@ export { Connection } from "./connection/Connection";
|
||||
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
|
||||
export { SqliteConnection } from "./connection/SqliteConnection";
|
||||
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
|
||||
export { SqliteIntrospector } from "./connection/SqliteIntrospector";
|
||||
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||
|
||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||
|
||||
@@ -28,3 +30,5 @@ export const DatabaseEvents = {
|
||||
export { MutatorEvents, RepositoryEvents };
|
||||
|
||||
export * as DataPermissions from "./permissions";
|
||||
|
||||
export { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||
|
||||
@@ -105,13 +105,13 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
||||
}
|
||||
|
||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||
const conn = this.connectionEntity;
|
||||
const { other, otherRef } = this.getQueryInfo(entity);
|
||||
|
||||
return {
|
||||
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)
|
||||
.innerJoin(...join)
|
||||
.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>) {
|
||||
this.em = em;
|
||||
|
||||
//this.connectionEntity.addField(new RelationField(this.source.entity));
|
||||
//this.connectionEntity.addField(new RelationField(this.target.entity));
|
||||
this.connectionEntity.addField(RelationField.create(this, this.source));
|
||||
this.connectionEntity.addField(RelationField.create(this, this.target));
|
||||
const sourceField = RelationField.create(this, this.source);
|
||||
const targetField = RelationField.create(this, this.target);
|
||||
|
||||
// @todo: check this
|
||||
for (const field of this.additionalFields) {
|
||||
this.source.entity.addField(new VirtualField(this.connectionTableMappedName));
|
||||
this.target.entity.addField(new VirtualField(this.connectionTableMappedName));
|
||||
if (em.hasEntity(this.connectionEntity)) {
|
||||
// @todo: also check for correct signatures of field
|
||||
if (!this.connectionEntity.hasField(sourceField)) {
|
||||
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 {
|
||||
|
||||
@@ -88,7 +88,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PrimaryFieldType } from "core";
|
||||
import type { Entity, EntityManager } from "../entities";
|
||||
import {
|
||||
type EntityRelation,
|
||||
ManyToManyRelation,
|
||||
type MutationOperation,
|
||||
MutationOperations,
|
||||
RelationField
|
||||
@@ -26,11 +27,26 @@ export class RelationMutator {
|
||||
*/
|
||||
getRelationalKeys(): 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) => {
|
||||
const info = r.helper(this.entity.name).getMutationInfo();
|
||||
references.push(info.reference);
|
||||
info.local_field && references.push(info.local_field);
|
||||
});
|
||||
|
||||
return references;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type StaticDecode,
|
||||
StringEnum,
|
||||
Type,
|
||||
Value
|
||||
Value,
|
||||
isObject
|
||||
} from "core/utils";
|
||||
import { WhereBuilder, type WhereQuery } from "../entities";
|
||||
|
||||
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
|
||||
>;
|
||||
|
||||
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) => {
|
||||
let _value = typeof value === "string" ? [value] : value;
|
||||
// images
|
||||
// images,comments
|
||||
// ["images","comments"]
|
||||
// { "images": {} }
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.every((v) => typeof v === "string")) {
|
||||
throw new Error("Invalid 'with' schema");
|
||||
}
|
||||
|
||||
_value = value.reduce((acc, v) => {
|
||||
acc[v] = {};
|
||||
return acc;
|
||||
}, {} as RepoWithSchema);
|
||||
if (!Array.isArray(value) && isObject(value)) {
|
||||
console.log("is object");
|
||||
return value 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);
|
||||
|
||||
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
|
||||
offset?: number;
|
||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||
select?: string[];
|
||||
with?: string[] | Record<string, RepoQueryIn>;
|
||||
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||
join?: string[];
|
||||
where?: WhereQuery;
|
||||
};
|
||||
|
||||
@@ -63,6 +63,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
|
||||
});
|
||||
});
|
||||
|
||||
hono.all("*", (c) => c.notFound());
|
||||
|
||||
this.ctx.server.route(this.config.basepath, hono);
|
||||
|
||||
// register flows
|
||||
|
||||
+12
-4
@@ -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 {
|
||||
getDefaultConfig,
|
||||
@@ -6,11 +13,12 @@ export {
|
||||
type ModuleConfigs,
|
||||
type ModuleSchemas,
|
||||
type ModuleManagerOptions,
|
||||
type ModuleBuildContext
|
||||
type ModuleBuildContext,
|
||||
type InitialModuleConfigs
|
||||
} from "./modules/ModuleManager";
|
||||
|
||||
export * as middlewares from "modules/middlewares";
|
||||
export { registries } from "modules/registries";
|
||||
|
||||
export type * from "./adapter";
|
||||
export { Api, type ApiOptions } from "./Api";
|
||||
export type { MediaFieldSchema } from "media/AppMedia";
|
||||
export type { UserFieldSchema } from "auth/AppAuth";
|
||||
|
||||
@@ -40,7 +40,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
let adapter: StorageAdapter;
|
||||
try {
|
||||
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.setBuilt();
|
||||
@@ -53,8 +54,6 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
index(media).on(["path"], true).on(["reference"]);
|
||||
})
|
||||
);
|
||||
|
||||
this.setBuilt();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error(
|
||||
@@ -124,11 +123,12 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
||||
async (e) => {
|
||||
const mutator = em.mutator(media);
|
||||
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);
|
||||
console.log("App:storage:file uploaded", e);
|
||||
return { data };
|
||||
},
|
||||
"sync"
|
||||
{ mode: "sync", id: "add-data-media" }
|
||||
);
|
||||
|
||||
// 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);
|
||||
},
|
||||
"sync"
|
||||
{ mode: "sync", id: "delete-data-media" }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
export type MediaApiOptions = BaseModuleApiOptions & {};
|
||||
@@ -10,15 +16,36 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
};
|
||||
}
|
||||
|
||||
getFiles() {
|
||||
return this.get(["files"]);
|
||||
listFiles() {
|
||||
return this.get<FileListObject[]>(["files"]);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -32,10 +59,93 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
});
|
||||
}
|
||||
|
||||
uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return this.post(["upload"], formData);
|
||||
protected uploadFile(
|
||||
body: File | ReadableStream,
|
||||
opts?: {
|
||||
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) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { tbValidator as tb } from "core";
|
||||
import { Type } from "core/utils";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { isDebug, tbValidator as tb } from "core";
|
||||
import { HttpStatus, Type, getFileFromContext } from "core/utils";
|
||||
import type { StorageAdapter } from "media";
|
||||
import { StorageEvents, getRandomizedFilename } from "media";
|
||||
import { Controller } from "modules/Controller";
|
||||
@@ -42,7 +41,6 @@ export class MediaController extends Controller {
|
||||
if (!filename) {
|
||||
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 }));
|
||||
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" });
|
||||
});
|
||||
|
||||
const uploadSizeMiddleware = bodyLimit({
|
||||
maxSize: this.getStorage().getConfig().body_max_size,
|
||||
onError: (c: any) => {
|
||||
return c.text(`Payload exceeds ${this.getStorage().getConfig().body_max_size}`, 413);
|
||||
}
|
||||
});
|
||||
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
if (isDebug()) {
|
||||
hono.post("/inspect", async (c) => {
|
||||
const file = await getFileFromContext(c);
|
||||
return c.json({
|
||||
type: file?.type,
|
||||
name: file?.name,
|
||||
size: file?.size
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// upload file
|
||||
// @todo: add required type for "upload endpoints"
|
||||
hono.post("/upload/:filename", uploadSizeMiddleware, async (c) => {
|
||||
const { filename } = c.req.param();
|
||||
if (!filename) {
|
||||
throw new Error("No file name provided");
|
||||
hono.post("/upload/:filename?", async (c) => {
|
||||
const reqname = c.req.param("filename");
|
||||
|
||||
const body = await getFileFromContext(c);
|
||||
if (!body) {
|
||||
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);
|
||||
console.log("----file", file);
|
||||
return c.json(await this.getStorage().uploadFile(file, filename));
|
||||
const filename = reqname ?? getRandomizedFilename(body as File);
|
||||
const res = await this.getStorage().uploadFile(body, filename);
|
||||
|
||||
return c.json(res, HttpStatus.CREATED);
|
||||
});
|
||||
|
||||
// add upload file to entity
|
||||
@@ -89,23 +102,21 @@ export class MediaController extends Controller {
|
||||
overwrite: Type.Optional(booleanLike)
|
||||
})
|
||||
),
|
||||
uploadSizeMiddleware,
|
||||
async (c) => {
|
||||
const entity_name = c.req.param("entity");
|
||||
const field_name = c.req.param("field");
|
||||
const entity_id = Number.parseInt(c.req.param("id"));
|
||||
console.log("params", { entity_name, field_name, entity_id });
|
||||
|
||||
// check if entity exists
|
||||
const entity = this.media.em.entity(entity_name);
|
||||
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
|
||||
const field = entity.field(field_name);
|
||||
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";
|
||||
@@ -127,7 +138,10 @@ export class MediaController extends Controller {
|
||||
if (count >= max_items) {
|
||||
// if overwrite not set, abort early
|
||||
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
|
||||
@@ -135,7 +149,7 @@ export class MediaController extends Controller {
|
||||
if (count > max_items) {
|
||||
return c.json(
|
||||
{ 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) {
|
||||
return c.json(
|
||||
{ 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_name = getRandomizedFilename(file as File);
|
||||
const info = await this.getStorage().uploadFile(file, file_name, true);
|
||||
const file = await getFileFromContext(c);
|
||||
if (!file) {
|
||||
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);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ export function buildMediaSchema() {
|
||||
config: adapter.schema
|
||||
},
|
||||
{
|
||||
title: name,
|
||||
title: adapter.schema?.title ?? name,
|
||||
description: adapter.schema?.description,
|
||||
additionalProperties: false
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type EmitsEvents, EventManager } from "core/events";
|
||||
import type { TSchema } from "core/utils";
|
||||
import { type Context, Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import { type TSchema, isFile } from "core/utils";
|
||||
import { isMimeType } from "media/storage/mime-types-tiny";
|
||||
import * as StorageEvents from "./events";
|
||||
import type { FileUploadedEventData } from "./events";
|
||||
|
||||
@@ -12,7 +11,7 @@ export type FileListObject = {
|
||||
};
|
||||
|
||||
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 = {
|
||||
name: string;
|
||||
meta: FileMeta;
|
||||
@@ -38,7 +37,7 @@ export interface StorageAdapter {
|
||||
}
|
||||
|
||||
export type StorageConfig = {
|
||||
body_max_size: number;
|
||||
body_max_size?: number;
|
||||
};
|
||||
|
||||
export class Storage implements EmitsEvents {
|
||||
@@ -55,7 +54,7 @@ export class Storage implements EmitsEvents {
|
||||
this.#adapter = adapter;
|
||||
this.config = {
|
||||
...config,
|
||||
body_max_size: config.body_max_size ?? 20 * 1024 * 1024
|
||||
body_max_size: config.body_max_size
|
||||
};
|
||||
|
||||
this.emgr = emgr ?? new EventManager();
|
||||
@@ -82,26 +81,33 @@ export class Storage implements EmitsEvents {
|
||||
noEmit?: boolean
|
||||
): Promise<FileUploadedEventData> {
|
||||
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) {
|
||||
case "undefined":
|
||||
throw new Error("Failed to upload file");
|
||||
case "string": {
|
||||
// get object meta
|
||||
const meta = await this.#adapter.getObjectMeta(name);
|
||||
if (!meta) {
|
||||
throw new Error("Failed to get object meta");
|
||||
}
|
||||
if (typeof result === "object") {
|
||||
info = result;
|
||||
} else if (isFile(file)) {
|
||||
info.meta.size = file.size;
|
||||
info.meta.type = file.type;
|
||||
}
|
||||
|
||||
info = { name, meta, etag: result };
|
||||
break;
|
||||
// try to get better meta info
|
||||
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 = result;
|
||||
break;
|
||||
info.meta = meta;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
@@ -113,7 +119,10 @@ export class Storage implements EmitsEvents {
|
||||
}
|
||||
};
|
||||
if (!noEmit) {
|
||||
await this.emgr.emit(new StorageEvents.FileUploadedEvent(eventData));
|
||||
const result = await this.emgr.emit(new StorageEvents.FileUploadedEvent(eventData));
|
||||
if (result.returned) {
|
||||
return result.params;
|
||||
}
|
||||
}
|
||||
|
||||
return eventData;
|
||||
@@ -127,102 +136,4 @@ export class Storage implements EmitsEvents {
|
||||
async fileExists(name: string) {
|
||||
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(),
|
||||
upload_preset: Type.Optional(Type.String())
|
||||
},
|
||||
{ title: "Cloudinary" }
|
||||
{ title: "Cloudinary", description: "Cloudinary media storage" }
|
||||
);
|
||||
|
||||
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
||||
/*export type CloudinaryConfig = {
|
||||
cloud_name: string;
|
||||
api_key: string;
|
||||
api_secret: string;
|
||||
upload_preset?: string;
|
||||
};*/
|
||||
|
||||
type CloudinaryObject = {
|
||||
asset_id: string;
|
||||
@@ -91,10 +85,8 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
}
|
||||
|
||||
async putObject(_key: string, body: FileBody) {
|
||||
//console.log("_key", _key);
|
||||
// remove extension, as it is added by cloudinary
|
||||
const key = _key.replace(/\.[a-z0-9]{2,5}$/, "");
|
||||
//console.log("key", key);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", body as any);
|
||||
@@ -117,21 +109,12 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
//console.log("putObject:cloudinary", formData);
|
||||
|
||||
if (!result.ok) {
|
||||
/*console.log(
|
||||
"failed to upload using cloudinary",
|
||||
Object.fromEntries(formData.entries()),
|
||||
result
|
||||
);*/
|
||||
return undefined;
|
||||
}
|
||||
|
||||
//console.log("putObject:result", result);
|
||||
|
||||
const data = (await result.json()) as CloudinaryPutObjectResponse;
|
||||
//console.log("putObject:result:json", data);
|
||||
|
||||
return {
|
||||
name: data.public_id + "." + data.format,
|
||||
@@ -154,7 +137,6 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
}
|
||||
}
|
||||
);
|
||||
//console.log("result", result);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("Failed to list objects");
|
||||
@@ -179,10 +161,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
}
|
||||
|
||||
async objectExists(key: string): Promise<boolean> {
|
||||
//console.log("--object exists?", key);
|
||||
const result = await this.headObject(key);
|
||||
//console.log("object exists", result);
|
||||
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
@@ -214,12 +193,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
const type = this.guessType(key) ?? "image";
|
||||
|
||||
const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`;
|
||||
//console.log("objectUrl", objectUrl);
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||
//console.log("url", this.getObjectUrl(key));
|
||||
const res = await fetch(this.getObjectUrl(key), {
|
||||
method: "GET",
|
||||
headers: pickHeaders(headers, ["range"])
|
||||
@@ -237,14 +214,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
||||
const formData = new FormData();
|
||||
formData.append("public_ids[]", key);
|
||||
|
||||
const result = await fetch(
|
||||
`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`,
|
||||
{
|
||||
method: "DELETE",
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
//console.log("deleteObject:result", result);
|
||||
await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, {
|
||||
method: "DELETE",
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
toJSON(secrets?: boolean) {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { type Static, Type, parse } from "core/utils";
|
||||
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
|
||||
import { type Static, Type, isFile, parse } from "core/utils";
|
||||
import type {
|
||||
FileBody,
|
||||
FileListObject,
|
||||
FileMeta,
|
||||
FileUploadPayload,
|
||||
StorageAdapter
|
||||
} from "../../Storage";
|
||||
import { guess } from "../../mime-types-tiny";
|
||||
|
||||
export const localAdapterConfig = Type.Object(
|
||||
{
|
||||
path: Type.String({ default: "./" })
|
||||
},
|
||||
{ title: "Local" }
|
||||
{ title: "Local", description: "Local file system storage" }
|
||||
);
|
||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||
|
||||
@@ -43,8 +49,9 @@ export class StorageLocalAdapter implements StorageAdapter {
|
||||
return fileStats;
|
||||
}
|
||||
|
||||
private async computeEtag(content: BufferSource): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
|
||||
private async computeEtag(body: FileBody): Promise<string> {
|
||||
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 hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
@@ -52,17 +59,16 @@ export class StorageLocalAdapter implements StorageAdapter {
|
||||
return `"${hashHex}"`;
|
||||
}
|
||||
|
||||
async putObject(key: string, body: FileBody): Promise<string> {
|
||||
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
|
||||
if (body === null) {
|
||||
throw new Error("Body is empty");
|
||||
}
|
||||
|
||||
// @todo: this is too hacky
|
||||
const file = body as File;
|
||||
|
||||
const filePath = `${this.config.path}/${key}`;
|
||||
await writeFile(filePath, file.stream());
|
||||
return await this.computeEtag(await file.arrayBuffer());
|
||||
const is_file = isFile(body);
|
||||
await writeFile(filePath, is_file ? body.stream() : body);
|
||||
|
||||
return await this.computeEtag(body);
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
PutObjectRequest
|
||||
} from "@aws-sdk/client-s3";
|
||||
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 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);
|
||||
//console.log("url", url);
|
||||
const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
|
||||
method: "GET"
|
||||
});
|
||||
//console.log("res", res);
|
||||
|
||||
// absolutely weird, but if only one object is there, it's an object, not an array
|
||||
const { Contents } = res.ListBucketResult;
|
||||
const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents];
|
||||
|
||||
//console.log(JSON.stringify(res.ListBucketResult, null, 2), objects);
|
||||
const transformed = transform(
|
||||
objects,
|
||||
(acc, obj) => {
|
||||
@@ -106,28 +104,21 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
||||
},
|
||||
[] as FileListObject[]
|
||||
);
|
||||
//console.log(transformed);
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
async putObject(
|
||||
key: string,
|
||||
body: FileBody | null,
|
||||
body: FileBody,
|
||||
// @todo: params must be added as headers, skipping for now
|
||||
params: Omit<PutObjectRequest, "Bucket" | "Key"> = {}
|
||||
) {
|
||||
const url = this.getUrl(key, {});
|
||||
//console.log("url", url);
|
||||
const res = await this.fetch(url, {
|
||||
method: "PUT",
|
||||
body
|
||||
});
|
||||
/*console.log("putObject:raw:res", {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});*/
|
||||
|
||||
if (res.ok) {
|
||||
// "df20fcb574dba1446cf5ec997940492b"
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Event } from "core/events";
|
||||
import { Event, InvalidEventReturn } from "core/events";
|
||||
import type { FileBody, FileUploadPayload } from "../Storage";
|
||||
|
||||
export type FileUploadedEventData = FileUploadPayload & {
|
||||
file: FileBody;
|
||||
};
|
||||
export class FileUploadedEvent extends Event<FileUploadedEventData> {
|
||||
export class FileUploadedEvent extends Event<FileUploadedEventData, object> {
|
||||
static override slug = "file-uploaded";
|
||||
|
||||
override validate(data: object) {
|
||||
if (typeof data !== "object") {
|
||||
throw new InvalidEventReturn("object", typeof data);
|
||||
}
|
||||
|
||||
return this.clone({
|
||||
// prepending result, so original is always kept
|
||||
...data,
|
||||
...this.params
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FileDeletedEvent extends Event<{ name: string }> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user