mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-03 16:46:00 +00:00
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
.history
|
||||||
**/*/.db/*
|
**/*/.db/*
|
||||||
**/*/.configs/*
|
**/*/.configs/*
|
||||||
|
**/*/.template/*
|
||||||
**/*/*.db
|
**/*/*.db
|
||||||
**/*/*.db-shm
|
**/*/*.db-shm
|
||||||
**/*/*.db-wal
|
**/*/*.db-wal
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
[](https://npmjs.org/package/bknd
|
[](https://npmjs.org/package/bknd)
|
||||||
"View this project on NPM")
|
|
||||||
[](https://www.npmjs.com/package/bknd)
|
[](https://www.npmjs.com/package/bknd)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
bknd simplifies app development by providing fully functional backend for data management,
|
<p align="center" width="100%">
|
||||||
authentication, workflows and media. Since it's lightweight and built on Web Standards, it can
|
<a href="https://stackblitz.com/github/bknd-io/bknd-examples?hideExplorer=1&embed=1&view=preview&startScript=example-admin-rich&initialPath=%2Fdata%2Fschema">
|
||||||
be deployed nearly anywhere, including running inside your framework of choice. No more
|
<strong>⭐ Live Demo</strong>
|
||||||
deploying multiple separate services!
|
</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.**
|
**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
|
> Please keep in mind that **bknd** is still under active development
|
||||||
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
> and therefore full backward compatibility is not guaranteed before reaching v1.0.0.
|
||||||
|
|
||||||
## ✨ Features
|
## Size
|
||||||
**📊 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.
|
|
||||||
|
|
||||||
**🔐 Auth**: Easily implement reliable authentication strategies.
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
**🖼️ Media**: Effortlessly manage and serve all your media files.
|
## Motivation
|
||||||
- Upload files with ease.
|
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.
|
||||||
- Adapter-based support for S3, S3-compatible storage (e.g., R2, Tigris), and Cloudinary.
|
|
||||||
|
|
||||||
**🔄 Flows** (UI integration coming soon!): Design and run workflows with seamless automation.
|
**The solution:** A backend system that only assumes and implements primitive details, integrates into multiple environments, and adheres to industry standards.
|
||||||
- Create and run workflows with trigger-based automation:
|
|
||||||
- Manual triggers or events from data, auth, media, or server actions.
|
## Features
|
||||||
- HTTP triggers for external integrations.
|
* ⚡ Instant backend with full REST API:
|
||||||
- Define tasks in sequence, parallel, or loops, with conditional execution.
|
* **Data**: Define, query, and control your data with ease.
|
||||||
- Use reusable sub-workflows to organize complex processes.
|
* **Auth**: Easily implement reliable authentication strategies.
|
||||||
- Leverage OpenAPI specifications for API-based tasks.
|
* **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
|
## 🚀 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"] });
|
const get = api.readMany("a".repeat(300), { select: ["id", "name"] });
|
||||||
expect(get.request.method).toBe("GET");
|
expect(get.request.method).toBe("GET");
|
||||||
expect(new URL(get.request.url).pathname).toBe(`/api/data/${"a".repeat(300)}`);
|
expect(new URL(get.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(300)}`);
|
||||||
|
|
||||||
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
|
const post = api.readMany("a".repeat(1000), { select: ["id", "name"] });
|
||||||
expect(post.request.method).toBe("POST");
|
expect(post.request.method).toBe("POST");
|
||||||
expect(new URL(post.request.url).pathname).toBe(`/api/data/${"a".repeat(1000)}/query`);
|
expect(new URL(post.request.url).pathname).toBe(`/api/data/entity/${"a".repeat(1000)}/query`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns result", async () => {
|
it("returns result", async () => {
|
||||||
@@ -39,7 +39,7 @@ describe("DataApi", () => {
|
|||||||
const app = controller.getController();
|
const app = controller.getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
const res = (await app.request("/posts")) as Response;
|
const res = (await app.request("/entity/posts")) as Response;
|
||||||
const { data } = await res.json();
|
const { data } = await res.json();
|
||||||
expect(data.length).toEqual(3);
|
expect(data.length).toEqual(3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 () => {
|
it("fetches endpoint", async () => {
|
||||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||||
const api = new Api({ host });
|
const api = new Api({ host }, app.request as typeof fetch);
|
||||||
|
|
||||||
// @ts-expect-error it's protected
|
|
||||||
api.fetcher = app.request as typeof fetch;
|
|
||||||
|
|
||||||
const res = await api.get("/endpoint");
|
const res = await api.get("/endpoint");
|
||||||
expect(res.res.ok).toEqual(true);
|
expect(res.res.ok).toEqual(true);
|
||||||
@@ -41,10 +38,7 @@ describe("ModuleApi", () => {
|
|||||||
|
|
||||||
it("has accessible request", async () => {
|
it("has accessible request", async () => {
|
||||||
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
const app = new Hono().get("/endpoint", (c) => c.json({ foo: "bar" }));
|
||||||
const api = new Api({ host });
|
const api = new Api({ host }, app.request as typeof fetch);
|
||||||
|
|
||||||
// @ts-expect-error it's protected
|
|
||||||
api.fetcher = app.request as typeof fetch;
|
|
||||||
|
|
||||||
const promise = api.get("/endpoint");
|
const promise = api.get("/endpoint");
|
||||||
expect(promise.request).toBeDefined();
|
expect(promise.request).toBeDefined();
|
||||||
|
|||||||
@@ -70,4 +70,34 @@ describe("repros", async () => {
|
|||||||
|
|
||||||
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
|
expect(app.em.entities.map((e) => e.name)).toEqual(["media", "test"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.only("verify inversedBy", async () => {
|
||||||
|
const schema = proto.em(
|
||||||
|
{
|
||||||
|
products: proto.entity("products", {
|
||||||
|
title: proto.text()
|
||||||
|
}),
|
||||||
|
product_likes: proto.entity("product_likes", {
|
||||||
|
created_at: proto.date()
|
||||||
|
}),
|
||||||
|
users: proto.entity("users", {})
|
||||||
|
},
|
||||||
|
(fns, schema) => {
|
||||||
|
fns.relation(schema.product_likes).manyToOne(schema.products, { inversedBy: "likes" });
|
||||||
|
fns.relation(schema.product_likes).manyToOne(schema.users);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const app = createApp({ initialConfig: { data: schema.toJSON() } });
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
const info = (await (await app.server.request("/api/data/info/products")).json()) as any;
|
||||||
|
|
||||||
|
expect(info.fields).toEqual(["id", "title"]);
|
||||||
|
expect(info.relations.listable).toEqual([
|
||||||
|
{
|
||||||
|
entity: "product_likes",
|
||||||
|
ref: "likes"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ describe("authorize", () => {
|
|||||||
role: "admin"
|
role: "admin"
|
||||||
};
|
};
|
||||||
|
|
||||||
guard.setUserContext(user);
|
expect(guard.granted("read", user)).toBe(true);
|
||||||
|
expect(guard.granted("write", user)).toBe(true);
|
||||||
expect(guard.granted("read")).toBe(true);
|
|
||||||
expect(guard.granted("write")).toBe(true);
|
|
||||||
|
|
||||||
expect(() => guard.granted("something")).toThrow();
|
expect(() => guard.granted("something")).toThrow();
|
||||||
});
|
});
|
||||||
@@ -46,10 +44,8 @@ describe("authorize", () => {
|
|||||||
role: "admin"
|
role: "admin"
|
||||||
};
|
};
|
||||||
|
|
||||||
guard.setUserContext(user);
|
expect(guard.granted("read", user)).toBe(true);
|
||||||
|
expect(guard.granted("write", user)).toBe(true);
|
||||||
expect(guard.granted("read")).toBe(true);
|
|
||||||
expect(guard.granted("write")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard implicit allow", async () => {
|
test("guard implicit allow", async () => {
|
||||||
@@ -66,12 +62,12 @@ describe("authorize", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
guard.setUserContext({
|
const user = {
|
||||||
role: "admin"
|
role: "admin"
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(guard.granted("read")).toBe(true);
|
expect(guard.granted("read", user)).toBe(true);
|
||||||
expect(guard.granted("write")).toBe(true);
|
expect(guard.granted("write", user)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("guard with guest role implicit allow", async () => {
|
test("guard with guest role implicit allow", async () => {
|
||||||
|
|||||||
+150
-13
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Perf } from "../../src/core/utils";
|
import { Perf, isBlob, ucFirst } from "../../src/core/utils";
|
||||||
import * as reqres from "../../src/core/utils/reqres";
|
import * as utils from "../../src/core/utils";
|
||||||
import * as strings from "../../src/core/utils/strings";
|
|
||||||
|
|
||||||
async function wait(ms: number) {
|
async function wait(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -13,7 +12,7 @@ describe("Core Utils", async () => {
|
|||||||
describe("[core] strings", async () => {
|
describe("[core] strings", async () => {
|
||||||
test("objectToKeyValueArray", async () => {
|
test("objectToKeyValueArray", async () => {
|
||||||
const obj = { a: 1, b: 2, c: 3 };
|
const obj = { a: 1, b: 2, c: 3 };
|
||||||
const result = strings.objectToKeyValueArray(obj);
|
const result = utils.objectToKeyValueArray(obj);
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{ key: "a", value: 1 },
|
{ key: "a", value: 1 },
|
||||||
{ key: "b", value: 2 },
|
{ key: "b", value: 2 },
|
||||||
@@ -22,24 +21,24 @@ describe("Core Utils", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("snakeToPascalWithSpaces", async () => {
|
test("snakeToPascalWithSpaces", async () => {
|
||||||
const result = strings.snakeToPascalWithSpaces("snake_to_pascal");
|
const result = utils.snakeToPascalWithSpaces("snake_to_pascal");
|
||||||
expect(result).toBe("Snake To Pascal");
|
expect(result).toBe("Snake To Pascal");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("randomString", async () => {
|
test("randomString", async () => {
|
||||||
const result = strings.randomString(10);
|
const result = utils.randomString(10);
|
||||||
expect(result).toHaveLength(10);
|
expect(result).toHaveLength(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pascalToKebab", async () => {
|
test("pascalToKebab", async () => {
|
||||||
const result = strings.pascalToKebab("PascalCase");
|
const result = utils.pascalToKebab("PascalCase");
|
||||||
expect(result).toBe("pascal-case");
|
expect(result).toBe("pascal-case");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaceSimplePlaceholders", async () => {
|
test("replaceSimplePlaceholders", async () => {
|
||||||
const str = "Hello, {$name}!";
|
const str = "Hello, {$name}!";
|
||||||
const vars = { name: "John" };
|
const vars = { name: "John" };
|
||||||
const result = strings.replaceSimplePlaceholders(str, vars);
|
const result = utils.replaceSimplePlaceholders(str, vars);
|
||||||
expect(result).toBe("Hello, John!");
|
expect(result).toBe("Hello, John!");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -49,7 +48,7 @@ describe("Core Utils", async () => {
|
|||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.append("Content-Type", "application/json");
|
headers.append("Content-Type", "application/json");
|
||||||
headers.append("Authorization", "Bearer 123");
|
headers.append("Authorization", "Bearer 123");
|
||||||
const obj = reqres.headersToObject(headers);
|
const obj = utils.headersToObject(headers);
|
||||||
expect(obj).toEqual({
|
expect(obj).toEqual({
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
authorization: "Bearer 123"
|
authorization: "Bearer 123"
|
||||||
@@ -59,23 +58,74 @@ describe("Core Utils", async () => {
|
|||||||
test("replaceUrlParam", () => {
|
test("replaceUrlParam", () => {
|
||||||
const url = "/api/:id/:name";
|
const url = "/api/:id/:name";
|
||||||
const params = { id: "123", name: "test" };
|
const params = { id: "123", name: "test" };
|
||||||
const result = reqres.replaceUrlParam(url, params);
|
const result = utils.replaceUrlParam(url, params);
|
||||||
expect(result).toBe("/api/123/test");
|
expect(result).toBe("/api/123/test");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("encode", () => {
|
test("encode", () => {
|
||||||
const obj = { id: "123", name: "test" };
|
const obj = { id: "123", name: "test" };
|
||||||
const result = reqres.encodeSearch(obj);
|
const result = utils.encodeSearch(obj);
|
||||||
expect(result).toBe("id=123&name=test");
|
expect(result).toBe("id=123&name=test");
|
||||||
|
|
||||||
const obj2 = { id: "123", name: ["test1", "test2"] };
|
const obj2 = { id: "123", name: ["test1", "test2"] };
|
||||||
const result2 = reqres.encodeSearch(obj2);
|
const result2 = utils.encodeSearch(obj2);
|
||||||
expect(result2).toBe("id=123&name=test1&name=test2");
|
expect(result2).toBe("id=123&name=test1&name=test2");
|
||||||
|
|
||||||
const obj3 = { id: "123", name: { test: "test" } };
|
const obj3 = { id: "123", name: { test: "test" } };
|
||||||
const result3 = reqres.encodeSearch(obj3, { encode: true });
|
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("guards", () => {
|
||||||
|
const types = {
|
||||||
|
blob: new Blob(),
|
||||||
|
file: new File([""], "file.txt"),
|
||||||
|
stream: new ReadableStream(),
|
||||||
|
arrayBuffer: new ArrayBuffer(10),
|
||||||
|
arrayBufferView: new Uint8Array(new ArrayBuffer(10))
|
||||||
|
};
|
||||||
|
|
||||||
|
const fns = [
|
||||||
|
[utils.isReadableStream, "stream"],
|
||||||
|
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
|
||||||
|
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
|
||||||
|
[utils.isArrayBuffer, "arrayBuffer"],
|
||||||
|
[utils.isArrayBufferView, "arrayBufferView"]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const additional = [0, 0.0, "", null, undefined, {}, []];
|
||||||
|
|
||||||
|
for (const [fn, type, _to_test] of fns) {
|
||||||
|
test(`is${ucFirst(type)}`, () => {
|
||||||
|
const to_test = _to_test ?? (Object.keys(types) as string[]);
|
||||||
|
for (const key of to_test) {
|
||||||
|
const value = types[key as keyof typeof types];
|
||||||
|
const result = fn(value);
|
||||||
|
expect(result).toBe(key === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of additional) {
|
||||||
|
const result = fn(value);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getContentName", () => {
|
||||||
|
const name = "test.json";
|
||||||
|
const text = "attachment; filename=" + name;
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Disposition": text
|
||||||
|
});
|
||||||
|
const request = new Request("http://example.com", {
|
||||||
|
headers
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(utils.getContentName(text)).toBe(name);
|
||||||
|
expect(utils.getContentName(headers)).toBe(name);
|
||||||
|
expect(utils.getContentName(request)).toBe(name);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("perf", async () => {
|
describe("perf", async () => {
|
||||||
@@ -108,4 +158,91 @@ describe("Core Utils", async () => {
|
|||||||
expect(count).toBe(2);
|
expect(count).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("objects", () => {
|
||||||
|
test("omitKeys", () => {
|
||||||
|
const objects = [
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["a"], { b: 2, c: 3 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["b"], { a: 1, c: 3 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["c"], { a: 1, b: 2 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["a", "b"], { c: 3 }],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, ["a", "b", "c"], {}]
|
||||||
|
] as [object, string[], object][];
|
||||||
|
|
||||||
|
for (const [obj, keys, expected] of objects) {
|
||||||
|
const result = utils.omitKeys(obj, keys as any);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isEqual", () => {
|
||||||
|
const objects = [
|
||||||
|
[1, 1, true],
|
||||||
|
[1, "1", false],
|
||||||
|
[1, 2, false],
|
||||||
|
["1", "1", true],
|
||||||
|
["1", "2", false],
|
||||||
|
[true, true, true],
|
||||||
|
[true, false, false],
|
||||||
|
[false, false, true],
|
||||||
|
[1, Number.NaN, false],
|
||||||
|
[Number.NaN, Number.NaN, true],
|
||||||
|
[null, null, true],
|
||||||
|
[null, undefined, false],
|
||||||
|
[undefined, undefined, true],
|
||||||
|
[new Map([["a", 1]]), new Map([["a", 1]]), true],
|
||||||
|
[new Map([["a", 1]]), new Map([["a", 2]]), false],
|
||||||
|
[new Map([["a", 1]]), new Map([["b", 1]]), false],
|
||||||
|
[
|
||||||
|
new Map([["a", 1]]),
|
||||||
|
new Map([
|
||||||
|
["a", 1],
|
||||||
|
["b", 2]
|
||||||
|
]),
|
||||||
|
false
|
||||||
|
],
|
||||||
|
[{ a: 1 }, { a: 1 }, true],
|
||||||
|
[{ a: 1 }, { a: 2 }, false],
|
||||||
|
[{ a: 1 }, { b: 1 }, false],
|
||||||
|
[{ a: "1" }, { a: "1" }, true],
|
||||||
|
[{ a: "1" }, { a: "2" }, false],
|
||||||
|
[{ a: "1" }, { b: "1" }, false],
|
||||||
|
[{ a: 1 }, { a: 1, b: 2 }, false],
|
||||||
|
[{ a: [1, 2, 3] }, { a: [1, 2, 3] }, true],
|
||||||
|
[{ a: [1, 2, 3] }, { a: [1, 2, 4] }, false],
|
||||||
|
[{ a: [1, 2, 3] }, { a: [1, 2, 3, 4] }, false],
|
||||||
|
[{ a: { b: 1 } }, { a: { b: 1 } }, true],
|
||||||
|
[{ a: { b: 1 } }, { a: { b: 2 } }, false],
|
||||||
|
[{ a: { b: 1 } }, { a: { c: 1 } }, false],
|
||||||
|
[{ a: { b: 1 } }, { a: { b: 1, c: 2 } }, false],
|
||||||
|
[[1, 2, 3], [1, 2, 3], true],
|
||||||
|
[[1, 2, 3], [1, 2, 4], false],
|
||||||
|
[[1, 2, 3], [1, 2, 3, 4], false],
|
||||||
|
[[{ a: 1 }], [{ a: 1 }], true],
|
||||||
|
[[{ a: 1 }], [{ a: 2 }], false],
|
||||||
|
[[{ a: 1 }], [{ b: 1 }], false]
|
||||||
|
] as [any, any, boolean][];
|
||||||
|
|
||||||
|
for (const [a, b, expected] of objects) {
|
||||||
|
const result = utils.isEqual(a, b);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getPath", () => {
|
||||||
|
const tests = [
|
||||||
|
[{ a: 1, b: 2, c: 3 }, "a", 1],
|
||||||
|
[{ a: 1, b: 2, c: 3 }, "b", 2],
|
||||||
|
[{ a: { b: 1 } }, "a.b", 1],
|
||||||
|
[{ a: { b: 1 } }, "a.b.c", null, null],
|
||||||
|
[{ a: { b: 1 } }, "a.b.c", 1, 1],
|
||||||
|
[[[1]], "0.0", 1]
|
||||||
|
] as [object, string, any, any][];
|
||||||
|
|
||||||
|
for (const [obj, path, expected, defaultValue] of tests) {
|
||||||
|
const result = utils.getPath(obj, path, defaultValue);
|
||||||
|
expect(result).toEqual(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ describe("[data] DataController", async () => {
|
|||||||
//console.log("app.routes", app.routes);
|
//console.log("app.routes", app.routes);
|
||||||
// create users
|
// create users
|
||||||
for await (const _user of fixtures.users) {
|
for await (const _user of fixtures.users) {
|
||||||
const res = await app.request("/users", {
|
const res = await app.request("/entity/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(_user)
|
body: JSON.stringify(_user)
|
||||||
});
|
});
|
||||||
@@ -131,7 +131,7 @@ describe("[data] DataController", async () => {
|
|||||||
|
|
||||||
// create posts
|
// create posts
|
||||||
for await (const _post of fixtures.posts) {
|
for await (const _post of fixtures.posts) {
|
||||||
const res = await app.request("/posts", {
|
const res = await app.request("/entity/posts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(_post)
|
body: JSON.stringify(_post)
|
||||||
});
|
});
|
||||||
@@ -145,7 +145,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (read many)", async () => {
|
test("/:entity (read many)", async () => {
|
||||||
const res = await app.request("/users");
|
const res = await app.request("/entity/users");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResponse;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
expect(data.meta.total).toBe(3);
|
||||||
@@ -156,7 +156,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/query (func query)", async () => {
|
test("/:entity/query (func query)", async () => {
|
||||||
const res = await app.request("/users/query", {
|
const res = await app.request("/entity/users/query", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
@@ -175,7 +175,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (read many, paginated)", async () => {
|
test("/:entity (read many, paginated)", async () => {
|
||||||
const res = await app.request("/users?limit=1&offset=2");
|
const res = await app.request("/entity/users?limit=1&offset=2");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResponse;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
expect(data.meta.total).toBe(3);
|
||||||
@@ -186,7 +186,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id (read one)", async () => {
|
test("/:entity/:id (read one)", async () => {
|
||||||
const res = await app.request("/users/3");
|
const res = await app.request("/entity/users/3");
|
||||||
const data = (await res.json()) as RepositoryResponse<EntityData>;
|
const data = (await res.json()) as RepositoryResponse<EntityData>;
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (update one)", async () => {
|
test("/:entity (update one)", async () => {
|
||||||
const res = await app.request("/users/3", {
|
const res = await app.request("/entity/users/3", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ name: "new name" })
|
body: JSON.stringify({ name: "new name" })
|
||||||
});
|
});
|
||||||
@@ -208,7 +208,7 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id/:reference (read references)", async () => {
|
test("/:entity/:id/:reference (read references)", async () => {
|
||||||
const res = await app.request("/users/1/posts");
|
const res = await app.request("/entity/users/1/posts");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResponse;
|
||||||
console.log("data", data);
|
console.log("data", data);
|
||||||
|
|
||||||
@@ -220,14 +220,14 @@ describe("[data] DataController", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id (delete one)", async () => {
|
test("/:entity/:id (delete one)", async () => {
|
||||||
const res = await app.request("/posts/2", {
|
const res = await app.request("/entity/posts/2", {
|
||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
|
const { data } = (await res.json()) as RepositoryResponse<EntityData>;
|
||||||
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
||||||
|
|
||||||
// verify
|
// verify
|
||||||
const res2 = await app.request("/posts");
|
const res2 = await app.request("/entity/posts");
|
||||||
const data2 = (await res2.json()) as RepositoryResponse;
|
const data2 = (await res2.json()) as RepositoryResponse;
|
||||||
expect(data2.meta.total).toBe(1);
|
expect(data2.meta.total).toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
const connection = conn ? conn : getDummyConnection().dummyConnection;
|
||||||
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
|
return new EntityManager(Object.values(s.entities), connection, s.relations, s.indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const assetsPath = `${import.meta.dir}/_assets`;
|
||||||
|
export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { App, createApp } from "../../src";
|
import { App, createApp } from "../../src";
|
||||||
import type { AuthResponse } from "../../src/auth";
|
import type { AuthResponse } from "../../src/auth";
|
||||||
|
import { auth } from "../../src/auth/middlewares";
|
||||||
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
import { randomString, secureRandomString, withDisabledConsole } from "../../src/core/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
import { disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ const fns = <Mode extends "cookie" | "token" = "token">(app: App, mode?: Mode) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: token ? `Bearer ${token}` : "",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...additional
|
...additional
|
||||||
};
|
};
|
||||||
@@ -210,4 +211,36 @@ describe("integration auth", () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("context is exclusive", async () => {
|
||||||
|
const app = createAuthApp();
|
||||||
|
await app.build();
|
||||||
|
const $fns = fns(app);
|
||||||
|
|
||||||
|
app.server.get("/get", auth(), async (c) => {
|
||||||
|
return c.json({
|
||||||
|
user: c.get("auth").user ?? null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.server.get("/wait", auth(), async (c) => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await $fns.login(configs.users.normal);
|
||||||
|
const me = await $fns.me(data.token);
|
||||||
|
expect(me.user.email).toBe(configs.users.normal.email);
|
||||||
|
|
||||||
|
app.server.request("/wait", {
|
||||||
|
headers: { Authorization: `Bearer ${data.token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
{
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
const res = await app.server.request("/get");
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.user).toBe(null);
|
||||||
|
expect(await $fns.me()).toEqual({ user: null as any });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { createApp } from "../../src";
|
||||||
|
import { Api } from "../../src/Api";
|
||||||
|
|
||||||
|
describe("integration config", () => {
|
||||||
|
it("should create an entity", async () => {
|
||||||
|
const app = createApp();
|
||||||
|
await app.build();
|
||||||
|
const api = new Api({
|
||||||
|
host: "http://localhost",
|
||||||
|
fetcher: app.server.request as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
// create entity
|
||||||
|
await api.system.addConfig("data", "entities.posts", {
|
||||||
|
name: "posts",
|
||||||
|
config: { sort_field: "id", sort_dir: "asc" },
|
||||||
|
fields: { id: { type: "primary", name: "id" }, asdf: { type: "text" } },
|
||||||
|
type: "regular"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.em.entities.map((e) => e.name)).toContain("posts");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,56 +1,96 @@
|
|||||||
import { describe, test } from "bun:test";
|
/// <reference types="@types/bun" />
|
||||||
import { Hono } from "hono";
|
|
||||||
import { Guard } from "../../src/auth";
|
|
||||||
import { EventManager } from "../../src/core/events";
|
|
||||||
import { EntityManager } from "../../src/data";
|
|
||||||
import { AppMedia } from "../../src/media/AppMedia";
|
|
||||||
import { MediaController } from "../../src/media/api/MediaController";
|
|
||||||
import { getDummyConnection } from "../helper";
|
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
|
import { createApp, registries } from "../../src";
|
||||||
|
import { StorageLocalAdapter } from "../../src/adapter/node";
|
||||||
|
import { mergeObject, randomString } from "../../src/core/utils";
|
||||||
|
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||||
|
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
/**
|
beforeAll(() => {
|
||||||
* R2
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
* value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,
|
});
|
||||||
* Node writefile
|
|
||||||
* data: string | NodeJS.ArrayBufferView | Iterable<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | Stream,
|
|
||||||
*/
|
|
||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
|
||||||
describe.skipIf(ALL_TESTS)("MediaController", () => {
|
|
||||||
test("..", async () => {
|
|
||||||
const ctx: any = {
|
|
||||||
em: new EntityManager([], dummyConnection, []),
|
|
||||||
guard: new Guard(),
|
|
||||||
emgr: new EventManager(),
|
|
||||||
server: new Hono()
|
|
||||||
};
|
|
||||||
|
|
||||||
const media = new AppMedia(
|
const path = `${assetsPath}/image.png`;
|
||||||
// @ts-ignore
|
|
||||||
|
async function makeApp(mediaOverride: Partial<TAppMediaConfig> = {}) {
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
|
media: mergeObject(
|
||||||
{
|
{
|
||||||
enabled: true,
|
enabled: true,
|
||||||
adapter: {
|
adapter: {
|
||||||
type: "s3",
|
type: "local",
|
||||||
config: {
|
config: {
|
||||||
access_key: process.env.R2_ACCESS_KEY as string,
|
path: assetsTmpPath
|
||||||
secret_access_key: process.env.R2_SECRET_ACCESS_KEY as string,
|
|
||||||
url: process.env.R2_URL as string
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ctx
|
mediaOverride
|
||||||
);
|
)
|
||||||
await media.build();
|
}
|
||||||
const app = new MediaController(media).getController();
|
});
|
||||||
|
|
||||||
const file = Bun.file(`${import.meta.dir}/adapters/icon.png`);
|
await app.build();
|
||||||
console.log("file", file);
|
return app;
|
||||||
const form = new FormData();
|
}
|
||||||
form.append("file", file);
|
|
||||||
|
|
||||||
await app.request("/upload/test.png", {
|
function makeName(ext: string) {
|
||||||
|
return randomString(10) + "." + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("MediaController", () => {
|
||||||
|
test("accepts direct", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
|
||||||
|
const file = Bun.file(path);
|
||||||
|
const name = makeName("png");
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: file
|
body: file
|
||||||
});
|
});
|
||||||
|
const result = (await res.json()) as any;
|
||||||
|
expect(result.name).toBe(name);
|
||||||
|
|
||||||
|
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||||
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
|
await destFile.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts form data", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
|
||||||
|
const file = Bun.file(path);
|
||||||
|
const name = makeName("png");
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
|
method: "POST",
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
const result = (await res.json()) as any;
|
||||||
|
expect(result.name).toBe(name);
|
||||||
|
|
||||||
|
const destFile = Bun.file(assetsTmpPath + "/" + name);
|
||||||
|
expect(destFile.exists()).resolves.toBe(true);
|
||||||
|
await destFile.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("limits body", async () => {
|
||||||
|
const app = await makeApp({ storage: { body_max_size: 1 } });
|
||||||
|
|
||||||
|
const file = await Bun.file(path);
|
||||||
|
const name = makeName("png");
|
||||||
|
const res = await app.server.request("/api/media/upload/" + name, {
|
||||||
|
method: "POST",
|
||||||
|
body: file
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(await Bun.file(assetsTmpPath + "/" + name).exists()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ describe("StorageLocalAdapter", () => {
|
|||||||
|
|
||||||
test("puts an object", async () => {
|
test("puts an object", async () => {
|
||||||
objects = (await adapter.listObjects()).length;
|
objects = (await adapter.listObjects()).length;
|
||||||
expect(await adapter.putObject(filename, file)).toBeString();
|
expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists objects", async () => {
|
test("lists objects", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { stripMark } from "../../src/core/utils";
|
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
|
||||||
import { entity, text } from "../../src/data";
|
import { entity, text } from "../../src/data";
|
||||||
|
import { Module } from "../../src/modules/Module";
|
||||||
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
import { ModuleManager, getDefaultConfig } from "../../src/modules/ModuleManager";
|
||||||
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
@@ -177,7 +178,7 @@ describe("ModuleManager", async () => {
|
|||||||
await mm.build();
|
await mm.build();
|
||||||
const configs = stripMark(mm.configs());
|
const configs = stripMark(mm.configs());
|
||||||
|
|
||||||
expect(mm.configs().server.admin.color_scheme).toBe("light");
|
expect(mm.configs().server.admin.color_scheme).toBeUndefined();
|
||||||
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
|
expect(() => mm.get("server").schema().patch("admin", { color_scheme: "violet" })).toThrow();
|
||||||
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
|
await mm.get("server").schema().patch("admin", { color_scheme: "dark" });
|
||||||
await mm.save();
|
await mm.save();
|
||||||
@@ -252,4 +253,131 @@ describe("ModuleManager", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// @todo: add tests for migrations (check "backup" and new version)
|
// @todo: add tests for migrations (check "backup" and new version)
|
||||||
|
|
||||||
|
describe("revert", async () => {
|
||||||
|
const failingModuleSchema = Type.Object({
|
||||||
|
value: Type.Optional(Type.Number())
|
||||||
|
});
|
||||||
|
class FailingModule extends Module<typeof failingModuleSchema> {
|
||||||
|
getSchema() {
|
||||||
|
return failingModuleSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async build() {
|
||||||
|
//console.log("building FailingModule", this.config);
|
||||||
|
if (this.config.value < 0) {
|
||||||
|
throw new Error("value must be positive");
|
||||||
|
}
|
||||||
|
this.setBuilt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TestModuleManager extends ModuleManager {
|
||||||
|
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
|
||||||
|
super(...args);
|
||||||
|
const [, options] = args;
|
||||||
|
// @ts-ignore
|
||||||
|
const initial = options?.initial?.failing ?? {};
|
||||||
|
this.modules["failing"] = new FailingModule(initial, this.ctx());
|
||||||
|
this.modules["failing"].setListener(async (c) => {
|
||||||
|
// @ts-ignore
|
||||||
|
await this.onModuleConfigUpdated("failing", c);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
|
||||||
|
afterEach(enableConsoleLog);
|
||||||
|
|
||||||
|
test("it builds", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
expect(mm).toBeDefined();
|
||||||
|
await mm.build();
|
||||||
|
expect(mm.toJSON()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it accepts config", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
// @ts-ignore
|
||||||
|
failing: { value: 2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
expect(mm.configs()["failing"].value).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it crashes on invalid", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
// @ts-ignore
|
||||||
|
failing: { value: -1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(mm.build()).rejects.toThrow(/value must be positive/);
|
||||||
|
expect(mm.configs()["failing"].value).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it correctly accepts valid", async () => {
|
||||||
|
const mockOnUpdated = mock(() => null);
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
onUpdated: async () => {
|
||||||
|
mockOnUpdated();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
// @ts-ignore
|
||||||
|
const f = mm.mutateConfigSafe("failing");
|
||||||
|
|
||||||
|
expect(f.set({ value: 2 })).resolves.toBeDefined();
|
||||||
|
expect(mockOnUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it reverts on safe mutate", async () => {
|
||||||
|
const mockOnUpdated = mock(() => null);
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection, {
|
||||||
|
initial: {
|
||||||
|
// @ts-ignore
|
||||||
|
failing: { value: 1 }
|
||||||
|
},
|
||||||
|
onUpdated: async () => {
|
||||||
|
mockOnUpdated();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await mm.build();
|
||||||
|
expect(mm.configs()["failing"].value).toBe(1);
|
||||||
|
|
||||||
|
// now safe mutate
|
||||||
|
// @ts-ignore
|
||||||
|
expect(mm.mutateConfigSafe("failing").set({ value: -2 })).rejects.toThrow(
|
||||||
|
/value must be positive/
|
||||||
|
);
|
||||||
|
expect(mm.configs()["failing"].value).toBe(1);
|
||||||
|
expect(mockOnUpdated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it only accepts schema mutating methods", async () => {
|
||||||
|
const { dummyConnection } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
await mm.build();
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const f = mm.mutateConfigSafe("failing");
|
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.has("value")).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.bypass()).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.clone()).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.get()).toThrow();
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(() => f.default()).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "bun:test";
|
|||||||
|
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Guard } from "../../src/auth";
|
import { Guard } from "../../src/auth";
|
||||||
|
import { DebugLogger } from "../../src/core";
|
||||||
import { EventManager } from "../../src/core/events";
|
import { EventManager } from "../../src/core/events";
|
||||||
import { Default, stripMark } from "../../src/core/utils";
|
import { Default, stripMark } from "../../src/core/utils";
|
||||||
import { EntityManager } from "../../src/data";
|
import { EntityManager } from "../../src/data";
|
||||||
@@ -17,6 +18,7 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
|||||||
emgr: new EventManager(),
|
emgr: new EventManager(),
|
||||||
guard: new Guard(),
|
guard: new Guard(),
|
||||||
flags: Module.ctx_flags,
|
flags: Module.ctx_flags,
|
||||||
|
logger: new DebugLogger(false),
|
||||||
...overrides
|
...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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+39
-12
@@ -49,13 +49,14 @@ if (types && !watch) {
|
|||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
|
async function buildApi() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
external: ["bun:test", "@libsql/client"],
|
external: ["bun:test", "@libsql/client", "bknd/client"],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
@@ -68,10 +69,12 @@ await tsup.build({
|
|||||||
delayTypes();
|
delayTypes();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI for direct imports
|
* Building UI for direct imports
|
||||||
*/
|
*/
|
||||||
|
async function buildUi() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
@@ -105,12 +108,14 @@ await tsup.build({
|
|||||||
delayTypes();
|
delayTypes();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI Elements
|
* Building UI Elements
|
||||||
* - tailwind-merge is mocked, no exclude
|
* - tailwind-merge is mocked, no exclude
|
||||||
* - ui/client is external, and after built replaced with "bknd/client"
|
* - ui/client is external, and after built replaced with "bknd/client"
|
||||||
*/
|
*/
|
||||||
|
async function buildUiElements() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
@@ -149,11 +154,12 @@ await tsup.build({
|
|||||||
delayTypes();
|
delayTypes();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(adapter: string): tsup.Options {
|
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
||||||
return {
|
return {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
@@ -162,28 +168,43 @@ function baseConfig(adapter: string): tsup.Options {
|
|||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
platform: "neutral",
|
platform: "neutral",
|
||||||
outDir: `dist/adapter/${adapter}`,
|
outDir: `dist/adapter/${adapter}`,
|
||||||
|
metafile: true,
|
||||||
|
splitting: false,
|
||||||
|
onSuccess: async () => {
|
||||||
|
delayTypes();
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0"
|
__isDev: "0",
|
||||||
|
...overrides.define
|
||||||
},
|
},
|
||||||
external: [
|
external: [
|
||||||
/^cloudflare*/,
|
/^cloudflare*/,
|
||||||
/^@?(hono|libsql).*?/,
|
/^@?(hono|libsql).*?/,
|
||||||
/^(bknd|react|next|node).*?/,
|
/^(bknd|react|next|node).*?/,
|
||||||
/.*\.(html)$/
|
/.*\.(html)$/,
|
||||||
],
|
...(Array.isArray(overrides.external) ? overrides.external : [])
|
||||||
metafile: true,
|
]
|
||||||
splitting: false,
|
|
||||||
treeshake: true,
|
|
||||||
onSuccess: async () => {
|
|
||||||
delayTypes();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildAdapters() {
|
||||||
|
// base adapter handles
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig(""),
|
||||||
|
entry: ["src/adapter/index.ts"],
|
||||||
|
outDir: "dist/adapter"
|
||||||
|
});
|
||||||
|
|
||||||
|
// specific adatpers
|
||||||
await tsup.build(baseConfig("remix"));
|
await tsup.build(baseConfig("remix"));
|
||||||
await tsup.build(baseConfig("bun"));
|
await tsup.build(baseConfig("bun"));
|
||||||
await tsup.build(baseConfig("astro"));
|
await tsup.build(baseConfig("astro"));
|
||||||
await tsup.build(baseConfig("cloudflare"));
|
await tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: [/^kysely/]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("vite"),
|
||||||
@@ -199,3 +220,9 @@ await tsup.build({
|
|||||||
...baseConfig("node"),
|
...baseConfig("node"),
|
||||||
platform: "node"
|
platform: "node"
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await buildApi();
|
||||||
|
await buildUi();
|
||||||
|
await buildUiElements();
|
||||||
|
await buildAdapters();
|
||||||
|
|||||||
+22
-11
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.6.1",
|
"version": "0.8.0",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, Remix, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -33,36 +33,41 @@
|
|||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^2.0.1",
|
"@cfworker/json-schema": "^2.0.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/lang-liquid": "^6.2.1",
|
||||||
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
"@libsql/client": "^0.14.0",
|
"@libsql/client": "^0.14.0",
|
||||||
|
"@mantine/core": "^7.13.4",
|
||||||
"@sinclair/typebox": "^0.32.34",
|
"@sinclair/typebox": "^0.32.34",
|
||||||
"@tanstack/react-form": "0.19.2",
|
"@tanstack/react-form": "0.19.2",
|
||||||
|
"@uiw/react-codemirror": "^4.23.6",
|
||||||
|
"@xyflow/react": "^12.3.2",
|
||||||
"aws4fetch": "^1.0.18",
|
"aws4fetch": "^1.0.18",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fast-xml-parser": "^4.4.0",
|
"fast-xml-parser": "^4.4.0",
|
||||||
"hono": "^4.6.12",
|
"hono": "^4.6.12",
|
||||||
|
"json-schema-form-react": "^0.0.2",
|
||||||
|
"json-schema-library": "^10.0.0-rc7",
|
||||||
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"kysely": "^0.27.4",
|
"kysely": "^0.27.4",
|
||||||
"liquidjs": "^10.15.0",
|
"liquidjs": "^10.15.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"swr": "^2.2.5",
|
"object-path-immutable": "^4.1.2",
|
||||||
"json-schema-form-react": "^0.0.2",
|
"picocolors": "^1.1.1",
|
||||||
"@uiw/react-codemirror": "^4.23.6",
|
"radix-ui": "^1.1.2",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"swr": "^2.2.5"
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
|
||||||
"@codemirror/lang-liquid": "^6.2.1",
|
|
||||||
"@xyflow/react": "^12.3.2",
|
|
||||||
"@mantine/core": "^7.13.4",
|
|
||||||
"@hello-pangea/dnd": "^17.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.613.0",
|
"@aws-sdk/client-s3": "^3.613.0",
|
||||||
|
"@bluwy/giget-core": "^0.1.2",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hono/typebox-validator": "^0.2.6",
|
"@hono/typebox-validator": "^0.2.6",
|
||||||
"@hono/vite-dev-server": "^0.17.0",
|
"@hono/vite-dev-server": "^0.17.0",
|
||||||
"@hono/zod-validator": "^0.4.1",
|
"@hono/zod-validator": "^0.4.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
|
||||||
"@rjsf/core": "^5.22.2",
|
"@rjsf/core": "^5.22.2",
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@tabler/icons-react": "3.18.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
@@ -71,8 +76,10 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
"esbuild-postcss": "^0.0.4",
|
"esbuild-postcss": "^0.0.4",
|
||||||
"jotai": "^2.10.1",
|
"jotai": "^2.10.1",
|
||||||
|
"kysely-d1": "^0.3.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
@@ -148,6 +155,10 @@
|
|||||||
"import": "./dist/adapter/cloudflare/index.js",
|
"import": "./dist/adapter/cloudflare/index.js",
|
||||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
"require": "./dist/adapter/cloudflare/index.cjs"
|
||||||
},
|
},
|
||||||
|
"./adapter": {
|
||||||
|
"types": "./dist/types/adapter/index.d.ts",
|
||||||
|
"import": "./dist/adapter/index.js"
|
||||||
|
},
|
||||||
"./adapter/vite": {
|
"./adapter/vite": {
|
||||||
"types": "./dist/types/adapter/vite/index.d.ts",
|
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||||
"import": "./dist/adapter/vite/index.js",
|
"import": "./dist/adapter/vite/index.js",
|
||||||
|
|||||||
+71
-26
@@ -17,13 +17,22 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
host: string;
|
host?: string;
|
||||||
user?: TApiUser;
|
|
||||||
token?: string;
|
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
key?: string;
|
key?: string;
|
||||||
localStorage?: boolean;
|
localStorage?: boolean;
|
||||||
};
|
fetcher?: typeof fetch;
|
||||||
|
verbose?: boolean;
|
||||||
|
verified?: boolean;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
token?: string;
|
||||||
|
user?: TApiUser;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
request: Request;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export type AuthState = {
|
export type AuthState = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -42,14 +51,26 @@ export class Api {
|
|||||||
public auth!: AuthApi;
|
public auth!: AuthApi;
|
||||||
public media!: MediaApi;
|
public media!: MediaApi;
|
||||||
|
|
||||||
constructor(private readonly options: ApiOptions) {
|
constructor(private options: ApiOptions = {}) {
|
||||||
if (options.user) {
|
// only mark verified if forced
|
||||||
this.user = options.user;
|
this.verified = options.verified === true;
|
||||||
this.token_transport = "none";
|
|
||||||
this.verified = true;
|
// prefer request if given
|
||||||
} else if (options.token) {
|
if ("request" in options) {
|
||||||
|
this.options.host = options.host ?? new URL(options.request.url).origin;
|
||||||
|
this.options.headers = options.headers ?? options.request.headers;
|
||||||
|
this.extractToken();
|
||||||
|
|
||||||
|
// then check for a token
|
||||||
|
} else if ("token" in options) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token);
|
this.updateToken(options.token);
|
||||||
|
|
||||||
|
// then check for an user object
|
||||||
|
} else if ("user" in options) {
|
||||||
|
this.token_transport = "none";
|
||||||
|
this.user = options.user;
|
||||||
|
this.verified = options.verified !== false;
|
||||||
} else {
|
} else {
|
||||||
this.extractToken();
|
this.extractToken();
|
||||||
}
|
}
|
||||||
@@ -58,7 +79,7 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.options.host;
|
return this.options.host ?? "http://localhost";
|
||||||
}
|
}
|
||||||
|
|
||||||
get tokenKey() {
|
get tokenKey() {
|
||||||
@@ -66,13 +87,15 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractToken() {
|
private extractToken() {
|
||||||
|
// if token has to be extracted, it's never verified
|
||||||
|
this.verified = false;
|
||||||
|
|
||||||
if (this.options.headers) {
|
if (this.options.headers) {
|
||||||
// try cookies
|
// try cookies
|
||||||
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
const cookieToken = getCookieValue(this.options.headers.get("cookie"), "auth");
|
||||||
if (cookieToken) {
|
if (cookieToken) {
|
||||||
this.updateToken(cookieToken);
|
|
||||||
this.token_transport = "cookie";
|
this.token_transport = "cookie";
|
||||||
this.verified = true;
|
this.updateToken(cookieToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +119,8 @@ export class Api {
|
|||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
updateToken(token?: string, rebuild?: boolean) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
|
this.verified = false;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
this.user = omit(decode(token).payload as any, ["iat", "iss", "exp"]) as any;
|
||||||
} else {
|
} else {
|
||||||
@@ -115,11 +140,15 @@ export class Api {
|
|||||||
if (rebuild) this.buildApis();
|
if (rebuild) this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
this.verified = verfied;
|
this.verified = verfied;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAuthVerified(): boolean {
|
||||||
|
return this.verified;
|
||||||
|
}
|
||||||
|
|
||||||
getAuthState(): AuthState {
|
getAuthState(): AuthState {
|
||||||
return {
|
return {
|
||||||
token: this.token,
|
token: this.token,
|
||||||
@@ -128,6 +157,11 @@ export class Api {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAuthenticated(): boolean {
|
||||||
|
const { token, user } = this.getAuthState();
|
||||||
|
return !!token && !!user;
|
||||||
|
}
|
||||||
|
|
||||||
async getVerifiedAuthState(): Promise<AuthState> {
|
async getVerifiedAuthState(): Promise<AuthState> {
|
||||||
await this.verifyAuth();
|
await this.verifyAuth();
|
||||||
return this.getAuthState();
|
return this.getAuthState();
|
||||||
@@ -140,11 +174,13 @@ export class Api {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.auth.me();
|
const { ok, data } = await this.auth.me();
|
||||||
if (!res.ok || !res.body.user) {
|
const user = data?.user;
|
||||||
|
if (!ok || !user) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.user = user;
|
||||||
this.markAuthVerified(true);
|
this.markAuthVerified(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.markAuthVerified(false);
|
this.markAuthVerified(false);
|
||||||
@@ -156,21 +192,30 @@ export class Api {
|
|||||||
return this.user || null;
|
return this.user || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildApis() {
|
getParams() {
|
||||||
const baseParams = {
|
return Object.freeze({
|
||||||
host: this.options.host,
|
host: this.baseUrl,
|
||||||
token: this.token,
|
token: this.token,
|
||||||
headers: this.options.headers,
|
headers: this.options.headers,
|
||||||
token_transport: this.token_transport
|
token_transport: this.token_transport,
|
||||||
};
|
verbose: this.options.verbose
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.system = new SystemApi(baseParams);
|
private buildApis() {
|
||||||
this.data = new DataApi(baseParams);
|
const baseParams = this.getParams();
|
||||||
this.auth = new AuthApi({
|
const fetcher = this.options.fetcher;
|
||||||
|
|
||||||
|
this.system = new SystemApi(baseParams, fetcher);
|
||||||
|
this.data = new DataApi(baseParams, fetcher);
|
||||||
|
this.auth = new AuthApi(
|
||||||
|
{
|
||||||
...baseParams,
|
...baseParams,
|
||||||
onTokenUpdate: (token) => this.updateToken(token, true)
|
onTokenUpdate: (token) => this.updateToken(token, true)
|
||||||
});
|
},
|
||||||
this.media = new MediaApi(baseParams);
|
fetcher
|
||||||
|
);
|
||||||
|
this.media = new MediaApi(baseParams, fetcher);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-29
@@ -1,8 +1,12 @@
|
|||||||
import type { CreateUserPayload } from "auth/AppAuth";
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
|
import { Api, type ApiOptions } from "bknd/client";
|
||||||
|
import { $console } from "core";
|
||||||
import { Event } from "core/events";
|
import { Event } from "core/events";
|
||||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||||
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
|
type ModuleBuildContext,
|
||||||
ModuleManager,
|
ModuleManager,
|
||||||
type ModuleManagerOptions,
|
type ModuleManagerOptions,
|
||||||
type Modules
|
type Modules
|
||||||
@@ -25,16 +29,22 @@ export class AppFirstBoot extends AppEvent {
|
|||||||
}
|
}
|
||||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
|
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
|
||||||
|
|
||||||
|
export type AppOptions = {
|
||||||
|
plugins?: AppPlugin[];
|
||||||
|
seed?: (ctx: ModuleBuildContext) => Promise<void>;
|
||||||
|
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
||||||
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
connection?:
|
connection?:
|
||||||
| Connection
|
| Connection
|
||||||
| {
|
| {
|
||||||
|
// @deprecated
|
||||||
type: "libsql";
|
type: "libsql";
|
||||||
config: LibSqlCredentials;
|
config: LibSqlCredentials;
|
||||||
};
|
}
|
||||||
|
| LibSqlCredentials;
|
||||||
initialConfig?: InitialModuleConfigs;
|
initialConfig?: InitialModuleConfigs;
|
||||||
plugins?: AppPlugin[];
|
options?: AppOptions;
|
||||||
options?: Omit<ModuleManagerOptions, "initial" | "onUpdated">;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppConfig = InitialModuleConfigs;
|
export type AppConfig = InitialModuleConfigs;
|
||||||
@@ -44,30 +54,34 @@ export class App {
|
|||||||
static readonly Events = AppEvents;
|
static readonly Events = AppEvents;
|
||||||
adminController?: AdminController;
|
adminController?: AdminController;
|
||||||
private trigger_first_boot = false;
|
private trigger_first_boot = false;
|
||||||
|
private plugins: AppPlugin[];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private connection: Connection,
|
private connection: Connection,
|
||||||
_initialConfig?: InitialModuleConfigs,
|
_initialConfig?: InitialModuleConfigs,
|
||||||
private plugins: AppPlugin[] = [],
|
private options?: AppOptions
|
||||||
moduleManagerOptions?: ModuleManagerOptions
|
|
||||||
) {
|
) {
|
||||||
|
this.plugins = options?.plugins ?? [];
|
||||||
this.modules = new ModuleManager(connection, {
|
this.modules = new ModuleManager(connection, {
|
||||||
...moduleManagerOptions,
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _initialConfig,
|
||||||
|
seed: options?.seed,
|
||||||
onUpdated: async (key, config) => {
|
onUpdated: async (key, config) => {
|
||||||
// if the EventManager was disabled, we assume we shouldn't
|
// if the EventManager was disabled, we assume we shouldn't
|
||||||
// respond to events, such as "onUpdated".
|
// respond to events, such as "onUpdated".
|
||||||
|
// this is important if multiple changes are done, and then build() is called manually
|
||||||
if (!this.emgr.enabled) {
|
if (!this.emgr.enabled) {
|
||||||
console.warn("[APP] config updated, but event manager is disabled, skip.");
|
$console.warn("App config updated, but event manager is disabled, skip.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[APP] config updated", key);
|
$console.log("App config updated", key);
|
||||||
await this.build({ sync: true, save: true });
|
// @todo: potentially double syncing
|
||||||
|
await this.build({ sync: true });
|
||||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||||
},
|
},
|
||||||
onFirstBoot: async () => {
|
onFirstBoot: async () => {
|
||||||
console.log("[APP] first boot");
|
$console.log("App first boot");
|
||||||
this.trigger_first_boot = true;
|
this.trigger_first_boot = true;
|
||||||
},
|
},
|
||||||
onServerInit: async (server) => {
|
onServerInit: async (server) => {
|
||||||
@@ -84,16 +98,10 @@ export class App {
|
|||||||
return this.modules.ctx().emgr;
|
return this.modules.ctx().emgr;
|
||||||
}
|
}
|
||||||
|
|
||||||
async build(options?: { sync?: boolean; drop?: boolean; save?: boolean }) {
|
async build(options?: { sync?: boolean }) {
|
||||||
|
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||||
await this.modules.build();
|
await this.modules.build();
|
||||||
|
|
||||||
if (options?.sync) {
|
|
||||||
const syncResult = await this.module.data.em
|
|
||||||
.schema()
|
|
||||||
.sync({ force: true, drop: options.drop });
|
|
||||||
//console.log("syncing", syncResult);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { guard, server } = this.modules.ctx();
|
const { guard, server } = this.modules.ctx();
|
||||||
|
|
||||||
// load system controller
|
// load system controller
|
||||||
@@ -107,12 +115,6 @@ export class App {
|
|||||||
|
|
||||||
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
||||||
|
|
||||||
server.all("/api/*", async (c) => c.notFound());
|
|
||||||
|
|
||||||
if (options?.save) {
|
|
||||||
await this.modules.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
// first boot is set from ModuleManager when there wasn't a config table
|
// first boot is set from ModuleManager when there wasn't a config table
|
||||||
if (this.trigger_first_boot) {
|
if (this.trigger_first_boot) {
|
||||||
this.trigger_first_boot = false;
|
this.trigger_first_boot = false;
|
||||||
@@ -121,7 +123,7 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutateConfig<Module extends keyof Modules>(module: Module) {
|
mutateConfig<Module extends keyof Modules>(module: Module) {
|
||||||
return this.modules.get(module).schema();
|
return this.modules.mutateConfigSafe(module);
|
||||||
}
|
}
|
||||||
|
|
||||||
get server() {
|
get server() {
|
||||||
@@ -132,7 +134,7 @@ export class App {
|
|||||||
return this.modules.ctx().em;
|
return this.modules.ctx().em;
|
||||||
}
|
}
|
||||||
|
|
||||||
get fetch(): any {
|
get fetch(): Hono["fetch"] {
|
||||||
return this.server.fetch;
|
return this.server.fetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +157,10 @@ export class App {
|
|||||||
return this.modules.version();
|
return this.modules.version();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isBuilt(): boolean {
|
||||||
|
return this.modules.isBuilt();
|
||||||
|
}
|
||||||
|
|
||||||
registerAdminController(config?: AdminControllerOptions) {
|
registerAdminController(config?: AdminControllerOptions) {
|
||||||
// register admin
|
// register admin
|
||||||
this.adminController = new AdminController(this, config);
|
this.adminController = new AdminController(this, config);
|
||||||
@@ -173,6 +179,15 @@ export class App {
|
|||||||
async createUser(p: CreateUserPayload) {
|
async createUser(p: CreateUserPayload) {
|
||||||
return this.module.auth.createUser(p);
|
return this.module.auth.createUser(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApi(options: Request | ApiOptions = {}) {
|
||||||
|
const fetcher = this.server.request as typeof fetch;
|
||||||
|
if (options instanceof Request) {
|
||||||
|
return new Api({ request: options, headers: options.headers, fetcher });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Api({ host: "http://localhost", ...options, fetcher });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(config: CreateAppConfig = {}) {
|
export function createApp(config: CreateAppConfig = {}) {
|
||||||
@@ -182,18 +197,25 @@ export function createApp(config: CreateAppConfig = {}) {
|
|||||||
if (Connection.isConnection(config.connection)) {
|
if (Connection.isConnection(config.connection)) {
|
||||||
connection = config.connection;
|
connection = config.connection;
|
||||||
} else if (typeof config.connection === "object") {
|
} else if (typeof config.connection === "object") {
|
||||||
|
if ("type" in config.connection) {
|
||||||
|
$console.warn(
|
||||||
|
"Using deprecated connection type 'libsql', use the 'config' object directly."
|
||||||
|
);
|
||||||
connection = new LibsqlConnection(config.connection.config);
|
connection = new LibsqlConnection(config.connection.config);
|
||||||
|
} else {
|
||||||
|
connection = new LibsqlConnection(config.connection);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
connection = new LibsqlConnection({ url: ":memory:" });
|
connection = new LibsqlConnection({ url: ":memory:" });
|
||||||
console.warn("[!] No connection provided, using in-memory database");
|
$console.warn("No connection provided, using in-memory database");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Could not create connection", e);
|
$console.error("Could not create connection", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
throw new Error("Invalid connection");
|
throw new Error("Invalid connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new App(connection, config.initialConfig, config.plugins, config.options);
|
return new App(connection, config.initialConfig, config.options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
import type { App } from "bknd";
|
||||||
import { Api, type ApiOptions, type App } from "bknd";
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
import { Api, type ApiOptions } from "bknd/client";
|
||||||
|
|
||||||
export type AstroBkndConfig = FrameworkBkndConfig;
|
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
|
||||||
|
|
||||||
type TAstro = {
|
type TAstro = {
|
||||||
request: Request;
|
request: Request;
|
||||||
@@ -13,18 +14,20 @@ export type Options = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
||||||
return new Api({
|
const api = new Api({
|
||||||
host: new URL(Astro.request.url).origin,
|
host: new URL(Astro.request.url).origin,
|
||||||
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
|
headers: options.mode === "dynamic" ? Astro.request.headers : undefined
|
||||||
});
|
});
|
||||||
|
await api.verifyAuth();
|
||||||
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export function serve(config: AstroBkndConfig = {}) {
|
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
|
||||||
return async (args: TAstro) => {
|
return async (args: Context) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
app = await createFrameworkApp(config);
|
app = await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
return app.fetch(args.request);
|
return app.fetch(args.request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
|
import { config } from "bknd/core";
|
||||||
import type { ServeOptions } from "bun";
|
import type { ServeOptions } from "bun";
|
||||||
import { config } from "core";
|
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
|
|
||||||
@@ -15,9 +16,9 @@ export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {})
|
|||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
|
|
||||||
if (!app) {
|
if (!app) {
|
||||||
|
registerLocalMediaAdapter();
|
||||||
app = await createRuntimeApp({
|
app = await createRuntimeApp({
|
||||||
...config,
|
...config,
|
||||||
registerLocalMedia: true,
|
|
||||||
serveStatic: serveStatic({ root })
|
serveStatic: serveStatic({ root })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -29,7 +30,6 @@ export function serve({
|
|||||||
distPath,
|
distPath,
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
initialConfig,
|
||||||
plugins,
|
|
||||||
options,
|
options,
|
||||||
port = config.server.default_port,
|
port = config.server.default_port,
|
||||||
onBuilt,
|
onBuilt,
|
||||||
@@ -43,7 +43,6 @@ export function serve({
|
|||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
initialConfig,
|
||||||
plugins,
|
|
||||||
options,
|
options,
|
||||||
onBuilt,
|
onBuilt,
|
||||||
buildConfig,
|
buildConfig,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { KyselyPluginRunner, SqliteConnection, SqliteIntrospector } from "bknd/data";
|
||||||
|
import type { QB } from "data/connection/Connection";
|
||||||
|
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||||
|
import { D1Dialect } from "kysely-d1";
|
||||||
|
|
||||||
|
export type D1ConnectionConfig = {
|
||||||
|
binding: D1Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CustomD1Dialect extends D1Dialect {
|
||||||
|
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||||
|
return new SqliteIntrospector(db, {
|
||||||
|
excludeTables: ["_cf_KV"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class D1Connection extends SqliteConnection {
|
||||||
|
constructor(private config: D1ConnectionConfig) {
|
||||||
|
const plugins = [new ParseJSONResultsPlugin()];
|
||||||
|
|
||||||
|
const kysely = new Kysely({
|
||||||
|
dialect: new CustomD1Dialect({ database: config.binding }),
|
||||||
|
plugins
|
||||||
|
});
|
||||||
|
super(kysely, {}, plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
override supportsBatching(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override supportsIndices(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async batch<Queries extends QB[]>(
|
||||||
|
queries: [...Queries]
|
||||||
|
): Promise<{
|
||||||
|
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
||||||
|
}> {
|
||||||
|
const db = this.config.binding;
|
||||||
|
|
||||||
|
const res = await db.batch(
|
||||||
|
queries.map((q) => {
|
||||||
|
const { sql, parameters } = q.compile();
|
||||||
|
return db.prepare(sql).bind(...parameters);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// let it run through plugins
|
||||||
|
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
|
||||||
|
const data: any = [];
|
||||||
|
for (const r of res) {
|
||||||
|
const rows = await kyselyPlugins.transformResultRows(r.results);
|
||||||
|
data.push(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-8
@@ -1,6 +1,47 @@
|
|||||||
import { isDebug } from "core";
|
import { registries } from "bknd";
|
||||||
import type { FileBody, StorageAdapter } from "../Storage";
|
import { isDebug } from "bknd/core";
|
||||||
import { guessMimeType } from "../mime-types";
|
import { StringEnum, Type } from "bknd/utils";
|
||||||
|
import type { FileBody, StorageAdapter } from "media/storage/Storage";
|
||||||
|
import { guess } from "media/storage/mime-types-tiny";
|
||||||
|
import { getBindings } from "./bindings";
|
||||||
|
|
||||||
|
export function makeSchema(bindings: string[] = []) {
|
||||||
|
return Type.Object(
|
||||||
|
{
|
||||||
|
binding: bindings.length > 0 ? StringEnum(bindings) : Type.Optional(Type.String())
|
||||||
|
},
|
||||||
|
{ title: "R2", description: "Cloudflare R2 storage" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMedia(env: Record<string, any>) {
|
||||||
|
const r2_bindings = getBindings(env, "R2Bucket");
|
||||||
|
|
||||||
|
registries.media.register(
|
||||||
|
"r2",
|
||||||
|
class extends StorageR2Adapter {
|
||||||
|
constructor(private config: any) {
|
||||||
|
const binding = r2_bindings.find((b) => b.key === config.binding);
|
||||||
|
if (!binding) {
|
||||||
|
throw new Error(`No R2Bucket found with key ${config.binding}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
super(binding?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
override getSchema() {
|
||||||
|
return makeSchema(r2_bindings.map((b) => b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
override toJSON() {
|
||||||
|
return {
|
||||||
|
...super.toJSON(),
|
||||||
|
config: this.config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapter for R2 storage
|
* Adapter for R2 storage
|
||||||
@@ -14,7 +55,7 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return undefined;
|
return makeSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
async putObject(key: string, body: FileBody) {
|
async putObject(key: string, body: FileBody) {
|
||||||
@@ -47,7 +88,8 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||||
let object: R2ObjectBody | null;
|
let object: R2ObjectBody | null;
|
||||||
const responseHeaders = new Headers({
|
const responseHeaders = new Headers({
|
||||||
"Accept-Ranges": "bytes"
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": guess(key)
|
||||||
});
|
});
|
||||||
|
|
||||||
//console.log("getObject:headers", headersToObject(headers));
|
//console.log("getObject:headers", headersToObject(headers));
|
||||||
@@ -97,10 +139,9 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
if (!metadata || Object.keys(metadata).length === 0) {
|
if (!metadata || Object.keys(metadata).length === 0) {
|
||||||
// guessing is especially required for dev environment (miniflare)
|
// guessing is especially required for dev environment (miniflare)
|
||||||
metadata = {
|
metadata = {
|
||||||
contentType: guessMimeType(object.key)
|
contentType: guess(object.key)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
//console.log("writeHttpMetadata", object.httpMetadata, metadata);
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(metadata)) {
|
for (const [key, value] of Object.entries(metadata)) {
|
||||||
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
const camelToDash = key.replace(/([A-Z])/g, "-$1").toLowerCase();
|
||||||
@@ -115,7 +156,7 @@ export class StorageR2Adapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: String(head.httpMetadata?.contentType ?? "application/octet-stream"),
|
type: String(head.httpMetadata?.contentType ?? guess(key)),
|
||||||
size: head.size
|
size: head.size
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type BindingTypeMap = {
|
||||||
|
D1Database: D1Database;
|
||||||
|
KVNamespace: KVNamespace;
|
||||||
|
DurableObjectNamespace: DurableObjectNamespace;
|
||||||
|
R2Bucket: R2Bucket;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetBindingType = keyof BindingTypeMap;
|
||||||
|
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
||||||
|
|
||||||
|
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
||||||
|
const bindings: BindingMap<T>[] = [];
|
||||||
|
for (const key in env) {
|
||||||
|
try {
|
||||||
|
if (env[key] && (env[key] as any).constructor.name === type) {
|
||||||
|
bindings.push({
|
||||||
|
key,
|
||||||
|
value: env[key] as BindingTypeMap[T]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBinding<T extends GetBindingType>(env: any, type: T): BindingMap<T> {
|
||||||
|
const bindings = getBindings(env, type);
|
||||||
|
if (bindings.length === 0) {
|
||||||
|
throw new Error(`No ${type} found in bindings`);
|
||||||
|
}
|
||||||
|
return bindings[0] as BindingMap<T>;
|
||||||
|
}
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import type { CreateAppConfig } from "bknd";
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import type { FrameworkBkndConfig } from "../index";
|
import { D1Connection } from "./D1Connection";
|
||||||
|
import { registerMedia } from "./StorageR2Adapter";
|
||||||
|
import { getBinding } from "./bindings";
|
||||||
import { getCached } from "./modes/cached";
|
import { getCached } from "./modes/cached";
|
||||||
import { getDurable } from "./modes/durable";
|
import { getDurable } from "./modes/durable";
|
||||||
import { getFresh, getWarm } from "./modes/fresh";
|
import { getFresh, getWarm } from "./modes/fresh";
|
||||||
|
|
||||||
export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> & {
|
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
|
||||||
app: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
bindings?: (env: Env) => {
|
bindings?: (args: Context<Env>) => {
|
||||||
kv?: KVNamespace;
|
kv?: KVNamespace;
|
||||||
dobj?: DurableObjectNamespace;
|
dobj?: DurableObjectNamespace;
|
||||||
|
db?: D1Database;
|
||||||
};
|
};
|
||||||
|
static?: "kv" | "assets";
|
||||||
key?: string;
|
key?: string;
|
||||||
keepAliveSeconds?: number;
|
keepAliveSeconds?: number;
|
||||||
forceHttps?: boolean;
|
forceHttps?: boolean;
|
||||||
@@ -21,28 +26,65 @@ export type CloudflareBkndConfig<Env = any> = Omit<FrameworkBkndConfig, "app"> &
|
|||||||
html?: string;
|
html?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Context = {
|
export type Context<Env = any> = {
|
||||||
request: Request;
|
request: Request;
|
||||||
env: any;
|
env: Env;
|
||||||
ctx: ExecutionContext;
|
ctx: ExecutionContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serve(config: CloudflareBkndConfig) {
|
let media_registered: boolean = false;
|
||||||
return {
|
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
if (!media_registered) {
|
||||||
const url = new URL(request.url);
|
registerMedia(context.env as any);
|
||||||
const manifest = config.manifest;
|
media_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (manifest) {
|
const appConfig = makeConfig(config, context);
|
||||||
|
const bindings = config.bindings?.(context);
|
||||||
|
if (!appConfig.connection) {
|
||||||
|
let db: D1Database | undefined;
|
||||||
|
if (bindings?.db) {
|
||||||
|
console.log("Using database from bindings");
|
||||||
|
db = bindings.db;
|
||||||
|
} else if (Object.keys(context.env ?? {}).length > 0) {
|
||||||
|
const binding = getBinding(context.env, "D1Database");
|
||||||
|
if (binding) {
|
||||||
|
console.log(`Using database from env "${binding.key}"`);
|
||||||
|
db = binding.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db) {
|
||||||
|
appConfig.connection = new D1Connection({ binding: db });
|
||||||
|
} else {
|
||||||
|
throw new Error("No database connection given");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return appConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
||||||
|
return {
|
||||||
|
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (config.manifest && config.static === "assets") {
|
||||||
|
console.warn("manifest is not useful with static 'assets'");
|
||||||
|
} else if (!config.manifest && config.static === "kv") {
|
||||||
|
throw new Error("manifest is required with static 'kv'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.manifest && config.static !== "assets") {
|
||||||
const pathname = url.pathname.slice(1);
|
const pathname = url.pathname.slice(1);
|
||||||
const assetManifest = JSON.parse(manifest);
|
const assetManifest = JSON.parse(config.manifest);
|
||||||
if (pathname && pathname in assetManifest) {
|
if (pathname && pathname in assetManifest) {
|
||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
|
|
||||||
hono.all("*", async (c, next) => {
|
hono.all("*", async (c, next) => {
|
||||||
const res = await serveStatic({
|
const res = await serveStatic({
|
||||||
path: `./${pathname}`,
|
path: `./${pathname}`,
|
||||||
manifest
|
manifest: config.manifest!
|
||||||
})(c as any, next);
|
})(c as any, next);
|
||||||
if (res instanceof Response) {
|
if (res instanceof Response) {
|
||||||
const ttl = 60 * 60 * 24 * 365;
|
const ttl = 60 * 60 * 24 * 365;
|
||||||
@@ -57,8 +99,6 @@ export function serve(config: CloudflareBkndConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.setAdminHtml = config.setAdminHtml && !!config.manifest;
|
|
||||||
|
|
||||||
const context = { request, env, ctx } as Context;
|
const context = { request, env, ctx } as Context;
|
||||||
const mode = config.mode ?? "warm";
|
const mode = config.mode ?? "warm";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
|
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||||
|
|
||||||
export * from "./cloudflare-workers.adapter";
|
export * from "./cloudflare-workers.adapter";
|
||||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
||||||
export { getCached } from "./modes/cached";
|
export { getCached } from "./modes/cached";
|
||||||
export { DurableBkndApp, getDurable } from "./modes/durable";
|
export { DurableBkndApp, getDurable } from "./modes/durable";
|
||||||
|
export { D1Connection, type D1ConnectionConfig };
|
||||||
|
export {
|
||||||
|
getBinding,
|
||||||
|
getBindings,
|
||||||
|
type BindingTypeMap,
|
||||||
|
type GetBindingType,
|
||||||
|
type BindingMap
|
||||||
|
} from "./bindings";
|
||||||
|
|
||||||
|
export function d1(config: D1ConnectionConfig) {
|
||||||
|
return new D1Connection(config);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createRuntimeApp } from "adapter";
|
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import type { CloudflareBkndConfig, Context } from "../index";
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
||||||
|
|
||||||
export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Context) {
|
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
|
||||||
const { kv } = config.bindings?.(env)!;
|
const { kv } = config.bindings?.(env)!;
|
||||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||||
const key = config.key ?? "app";
|
const key = config.key ?? "app";
|
||||||
@@ -16,7 +16,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
|
|||||||
|
|
||||||
const app = await createRuntimeApp(
|
const app = await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...config,
|
...makeCfConfig(config, { env, ctx, ...args }),
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
app.module.server.client.get("/__bknd/cache", async (c) => {
|
||||||
@@ -37,7 +37,7 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx }: Cont
|
|||||||
},
|
},
|
||||||
adminOptions: { html: config.html }
|
adminOptions: { html: config.html }
|
||||||
},
|
},
|
||||||
env
|
{ env, ctx, ...args }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!cachedConfig) {
|
if (!cachedConfig) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DurableObject } from "cloudflare:workers";
|
import { DurableObject } from "cloudflare:workers";
|
||||||
import { createRuntimeApp } from "adapter";
|
|
||||||
import type { CloudflareBkndConfig, Context } from "adapter/cloudflare";
|
|
||||||
import type { App, CreateAppConfig } from "bknd";
|
import type { App, CreateAppConfig } from "bknd";
|
||||||
|
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||||
|
import type { CloudflareBkndConfig, Context } from "../index";
|
||||||
|
|
||||||
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
||||||
const { dobj } = config.bindings?.(ctx.env)!;
|
const { dobj } = config.bindings?.(ctx.env)!;
|
||||||
@@ -17,7 +17,7 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
|||||||
const id = dobj.idFromName(key);
|
const id = dobj.idFromName(key);
|
||||||
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
||||||
|
|
||||||
const create_config = typeof config.app === "function" ? config.app(ctx.env) : config.app;
|
const create_config = makeConfig(config, ctx);
|
||||||
|
|
||||||
const res = await stub.fire(ctx.request, {
|
const res = await stub.fire(ctx.request, {
|
||||||
config: create_config,
|
config: create_config,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { createRuntimeApp } from "adapter";
|
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import type { CloudflareBkndConfig, Context } from "../index";
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
||||||
|
|
||||||
export async function makeApp(config: CloudflareBkndConfig, { env }: Context) {
|
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...config,
|
...makeCfConfig(config, ctx),
|
||||||
adminOptions: config.html ? { html: config.html } : undefined
|
adminOptions: config.html ? { html: config.html } : undefined
|
||||||
},
|
},
|
||||||
env
|
ctx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-49
@@ -1,59 +1,29 @@
|
|||||||
import type { IncomingMessage } from "node:http";
|
import { App, type CreateAppConfig } from "bknd";
|
||||||
import { App, type CreateAppConfig, registries } from "bknd";
|
import { config as $config } from "bknd/core";
|
||||||
import { config as $config } from "core";
|
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { StorageLocalAdapter } from "media/storage/adapters/StorageLocalAdapter";
|
|
||||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||||
|
|
||||||
export type BkndConfig<Env = any> = CreateAppConfig & {
|
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||||
app?: CreateAppConfig | ((env: Env) => CreateAppConfig);
|
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||||
onBuilt?: (app: App) => Promise<void>;
|
onBuilt?: (app: App) => Promise<void>;
|
||||||
beforeBuild?: (app: App) => Promise<void>;
|
beforeBuild?: (app: App) => Promise<void>;
|
||||||
buildConfig?: Parameters<App["build"]>[0];
|
buildConfig?: Parameters<App["build"]>[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FrameworkBkndConfig<Env = any> = BkndConfig<Env>;
|
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||||
|
|
||||||
export type RuntimeBkndConfig<Env = any> = BkndConfig<Env> & {
|
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||||
distPath?: string;
|
distPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function nodeRequestToRequest(req: IncomingMessage): Request {
|
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
|
||||||
let protocol = "http";
|
|
||||||
try {
|
|
||||||
protocol = req.headers["x-forwarded-proto"] as string;
|
|
||||||
} catch (e) {}
|
|
||||||
const host = req.headers.host;
|
|
||||||
const url = `${protocol}://${host}${req.url}`;
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
headers.append(key, value.join(", "));
|
|
||||||
} else if (value) {
|
|
||||||
headers.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = req.method || "GET";
|
|
||||||
return new Request(url, {
|
|
||||||
method,
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerLocalMediaAdapter() {
|
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): CreateAppConfig {
|
|
||||||
let additionalConfig: CreateAppConfig = {};
|
let additionalConfig: CreateAppConfig = {};
|
||||||
if ("app" in config && config.app) {
|
if ("app" in config && config.app) {
|
||||||
if (typeof config.app === "function") {
|
if (typeof config.app === "function") {
|
||||||
if (!env) {
|
if (!args) {
|
||||||
throw new Error("env is required when config.app is a function");
|
throw new Error("args is required when config.app is a function");
|
||||||
}
|
}
|
||||||
additionalConfig = config.app(env);
|
additionalConfig = config.app(args);
|
||||||
} else {
|
} else {
|
||||||
additionalConfig = config.app;
|
additionalConfig = config.app;
|
||||||
}
|
}
|
||||||
@@ -62,11 +32,11 @@ export function makeConfig<Env = any>(config: BkndConfig<Env>, env?: Env): Creat
|
|||||||
return { ...config, ...additionalConfig };
|
return { ...config, ...additionalConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFrameworkApp<Env = any>(
|
export async function createFrameworkApp<Args = any>(
|
||||||
config: FrameworkBkndConfig,
|
config: FrameworkBkndConfig,
|
||||||
env?: Env
|
args?: Args
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const app = App.create(makeConfig(config, env));
|
const app = App.create(makeConfig(config, args));
|
||||||
|
|
||||||
if (config.onBuilt) {
|
if (config.onBuilt) {
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
@@ -87,20 +57,14 @@ export async function createFrameworkApp<Env = any>(
|
|||||||
export async function createRuntimeApp<Env = any>(
|
export async function createRuntimeApp<Env = any>(
|
||||||
{
|
{
|
||||||
serveStatic,
|
serveStatic,
|
||||||
registerLocalMedia,
|
|
||||||
adminOptions,
|
adminOptions,
|
||||||
...config
|
...config
|
||||||
}: RuntimeBkndConfig & {
|
}: RuntimeBkndConfig & {
|
||||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||||
registerLocalMedia?: boolean;
|
|
||||||
adminOptions?: AdminControllerOptions | false;
|
adminOptions?: AdminControllerOptions | false;
|
||||||
},
|
},
|
||||||
env?: Env
|
env?: Env
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
if (registerLocalMedia) {
|
|
||||||
registerLocalMediaAdapter();
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = App.create(makeConfig(config, env));
|
const app = App.create(makeConfig(config, env));
|
||||||
|
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { Api, type App } from "bknd";
|
import { nodeRequestToRequest } from "adapter/utils";
|
||||||
import { type FrameworkBkndConfig, createFrameworkApp, nodeRequestToRequest } from "../index";
|
import type { App } from "bknd";
|
||||||
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
import { Api } from "bknd/client";
|
||||||
|
|
||||||
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
||||||
cleanSearch?: string[];
|
cleanSearch?: string[];
|
||||||
@@ -29,8 +31,10 @@ export function createApi({ req }: GetServerSidePropsContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
export function withApi<T>(handler: (ctx: GetServerSidePropsContext & { api: Api }) => T) {
|
||||||
return (ctx: GetServerSidePropsContext & { api: Api }) => {
|
return async (ctx: GetServerSidePropsContext & { api: Api }) => {
|
||||||
return handler({ ...ctx, api: createApi(ctx) });
|
const api = createApi(ctx);
|
||||||
|
await api.verifyAuth();
|
||||||
|
return handler({ ...ctx, api });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
export * from "./node.adapter";
|
import { registries } from "bknd";
|
||||||
export {
|
import {
|
||||||
StorageLocalAdapter,
|
type LocalAdapterConfig,
|
||||||
type LocalAdapterConfig
|
StorageLocalAdapter
|
||||||
} from "../../media/storage/adapters/StorageLocalAdapter";
|
} from "../../media/storage/adapters/StorageLocalAdapter";
|
||||||
export { registerLocalMediaAdapter } from "../index";
|
|
||||||
|
export * from "./node.adapter";
|
||||||
|
export { StorageLocalAdapter, type LocalAdapterConfig };
|
||||||
|
|
||||||
|
export function registerLocalMediaAdapter() {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { serve as honoServe } from "@hono/node-server";
|
import { serve as honoServe } from "@hono/node-server";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
import { registerLocalMediaAdapter } from "adapter/node/index";
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { config as $config } from "core";
|
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "../index";
|
import { config as $config } from "bknd/core";
|
||||||
|
|
||||||
export type NodeBkndConfig = RuntimeBkndConfig & {
|
export type NodeBkndConfig = RuntimeBkndConfig & {
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -37,9 +38,9 @@ export function serve({
|
|||||||
hostname,
|
hostname,
|
||||||
fetch: async (req: Request) => {
|
fetch: async (req: Request) => {
|
||||||
if (!app) {
|
if (!app) {
|
||||||
|
registerLocalMediaAdapter();
|
||||||
app = await createRuntimeApp({
|
app = await createRuntimeApp({
|
||||||
...config,
|
...config,
|
||||||
registerLocalMedia: true,
|
|
||||||
serveStatic: serveStatic({ root })
|
serveStatic: serveStatic({ root })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { useAuth } from "bknd/client";
|
||||||
import type { BkndAdminProps } from "bknd/ui";
|
import type { BkndAdminProps } from "bknd/ui";
|
||||||
import { Suspense, lazy, useEffect, useState } from "react";
|
import { Suspense, lazy, useEffect, useState } from "react";
|
||||||
|
|
||||||
export function adminPage(props?: BkndAdminProps) {
|
export function adminPage(props?: BkndAdminProps) {
|
||||||
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
const Admin = lazy(() => import("bknd/ui").then((mod) => ({ default: mod.Admin })));
|
||||||
return () => {
|
return () => {
|
||||||
|
const auth = useAuth();
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -13,7 +15,7 @@ export function adminPage(props?: BkndAdminProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<Admin {...props} />
|
<Admin withProvider={{ user: auth.user }} {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { type FrameworkBkndConfig, createFrameworkApp } from "adapter";
|
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
|
||||||
export type RemixBkndConfig = FrameworkBkndConfig;
|
export type RemixBkndConfig<Args = RemixContext> = FrameworkBkndConfig<Args>;
|
||||||
|
|
||||||
|
type RemixContext = {
|
||||||
|
request: Request;
|
||||||
|
};
|
||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
export function serve(config: RemixBkndConfig = {}) {
|
let building: boolean = false;
|
||||||
return async (args: { request: Request }) => {
|
|
||||||
if (!app) {
|
export async function getApp(config: RemixBkndConfig, args?: RemixContext) {
|
||||||
app = await createFrameworkApp(config);
|
if (building) {
|
||||||
|
while (building) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
}
|
}
|
||||||
|
if (app) return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
if (!app) {
|
||||||
|
app = await createFrameworkApp(config, args);
|
||||||
|
await app.build();
|
||||||
|
}
|
||||||
|
building = false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serve<Args extends RemixContext = RemixContext>(
|
||||||
|
config: RemixBkndConfig<Args> = {}
|
||||||
|
) {
|
||||||
|
return async (args: Args) => {
|
||||||
|
app = await createFrameworkApp(config, args);
|
||||||
return app.fetch(args.request);
|
return app.fetch(args.request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "adapter";
|
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
||||||
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
import { devServerConfig } from "./dev-server-config";
|
import { devServerConfig } from "./dev-server-config";
|
||||||
|
|
||||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
||||||
@@ -28,10 +29,10 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
||||||
|
registerLocalMediaAdapter();
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...config,
|
...config,
|
||||||
registerLocalMedia: true,
|
|
||||||
adminOptions:
|
adminOptions:
|
||||||
config.setAdminHtml === false
|
config.setAdminHtml === false
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async login(strategy: string, input: any) {
|
async login(strategy: string, input: any) {
|
||||||
const res = await this.post<AuthResponse>([strategy, "login"], input);
|
const res = await this.post<AuthResponse>([strategy, "login"], input, {
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
await this.options.onTokenUpdate?.(res.body.token);
|
||||||
}
|
}
|
||||||
@@ -23,7 +26,10 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async register(strategy: string, input: any) {
|
async register(strategy: string, input: any) {
|
||||||
const res = await this.post<AuthResponse>([strategy, "register"], input);
|
const res = await this.post<AuthResponse>([strategy, "register"], input, {
|
||||||
|
credentials: "include"
|
||||||
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
await this.options.onTokenUpdate?.(res.body.token);
|
await this.options.onTokenUpdate?.(res.body.token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export class AuthController extends Controller {
|
|||||||
return this.auth.ctx.guard;
|
return this.auth.ctx.guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.auth.ctx.em;
|
||||||
|
}
|
||||||
|
|
||||||
|
get userRepo() {
|
||||||
|
const entity_name = this.auth.config.entity_name;
|
||||||
|
return this.em.repo(entity_name as "users");
|
||||||
|
}
|
||||||
|
|
||||||
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
private registerStrategyActions(strategy: Strategy, mainHono: Hono<ServerEnv>) {
|
||||||
const actions = strategy.getActions?.();
|
const actions = strategy.getActions?.();
|
||||||
if (!actions) {
|
if (!actions) {
|
||||||
@@ -95,8 +104,10 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hono.get("/me", auth(), async (c) => {
|
hono.get("/me", auth(), async (c) => {
|
||||||
if (this.auth.authenticator.isUserLoggedIn()) {
|
const claims = c.get("auth")?.user;
|
||||||
return c.json({ user: this.auth.authenticator.getUser() });
|
if (claims) {
|
||||||
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
|
return c.json({ user });
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
return c.json({ user: null }, 403);
|
||||||
@@ -121,6 +132,6 @@ export class AuthController extends Controller {
|
|||||||
return c.json({ strategies, basepath });
|
return c.json({ strategies, basepath });
|
||||||
});
|
});
|
||||||
|
|
||||||
return hono;
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,17 +106,15 @@ export type AuthUserResolver = (
|
|||||||
identifier: string,
|
identifier: string,
|
||||||
profile: ProfileExchange
|
profile: ProfileExchange
|
||||||
) => Promise<SafeUser | undefined>;
|
) => Promise<SafeUser | undefined>;
|
||||||
|
type AuthClaims = SafeUser & {
|
||||||
|
iat: number;
|
||||||
|
iss?: string;
|
||||||
|
exp?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
export class Authenticator<Strategies extends Record<string, Strategy> = Record<string, Strategy>> {
|
||||||
private readonly strategies: Strategies;
|
private readonly strategies: Strategies;
|
||||||
private readonly config: AuthConfig;
|
private readonly config: AuthConfig;
|
||||||
private _claims:
|
|
||||||
| undefined
|
|
||||||
| (SafeUser & {
|
|
||||||
iat: number;
|
|
||||||
iss?: string;
|
|
||||||
exp?: number;
|
|
||||||
});
|
|
||||||
private readonly userResolver: AuthUserResolver;
|
private readonly userResolver: AuthUserResolver;
|
||||||
|
|
||||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
||||||
@@ -148,21 +146,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return this.strategies;
|
return this.strategies;
|
||||||
}
|
}
|
||||||
|
|
||||||
isUserLoggedIn(): boolean {
|
|
||||||
return this._claims !== undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUser(): SafeUser | undefined {
|
|
||||||
if (!this._claims) return;
|
|
||||||
|
|
||||||
const { iat, exp, iss, ...user } = this._claims;
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
resetUser() {
|
|
||||||
this._claims = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
strategy<
|
strategy<
|
||||||
StrategyName extends keyof Strategies,
|
StrategyName extends keyof Strategies,
|
||||||
Strat extends Strategy = Strategies[StrategyName]
|
Strat extends Strategy = Strategies[StrategyName]
|
||||||
@@ -206,7 +189,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
|
return sign(payload, secret, this.config.jwt?.alg ?? "HS256");
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(jwt: string): Promise<boolean> {
|
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
||||||
try {
|
try {
|
||||||
const payload = await verify(
|
const payload = await verify(
|
||||||
jwt,
|
jwt,
|
||||||
@@ -221,14 +204,10 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._claims = payload as any;
|
return payload as any;
|
||||||
return true;
|
} catch (e) {}
|
||||||
} catch (e) {
|
|
||||||
this.resetUser();
|
|
||||||
//console.error(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get cookieOptions(): CookieOptions {
|
private get cookieOptions(): CookieOptions {
|
||||||
@@ -258,8 +237,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestCookieRefresh(c: Context) {
|
async requestCookieRefresh(c: Context<ServerEnv>) {
|
||||||
if (this.config.cookie.renew) {
|
if (this.config.cookie.renew && c.get("auth")?.user) {
|
||||||
const token = await this.getAuthCookie(c);
|
const token = await this.getAuthCookie(c);
|
||||||
if (token) {
|
if (token) {
|
||||||
await this.setAuthCookie(c, token);
|
await this.setAuthCookie(c, token);
|
||||||
@@ -276,13 +255,14 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
await deleteCookie(c, "auth", this.cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(c: Context) {
|
async logout(c: Context<ServerEnv>) {
|
||||||
|
c.set("auth", undefined);
|
||||||
|
|
||||||
const cookie = await this.getAuthCookie(c);
|
const cookie = await this.getAuthCookie(c);
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
await this.deleteAuthCookie(c);
|
await this.deleteAuthCookie(c);
|
||||||
await addFlashMessage(c, "Signed out", "info");
|
await addFlashMessage(c, "Signed out", "info");
|
||||||
}
|
}
|
||||||
this.resetUser();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: move this to a server helper
|
// @todo: move this to a server helper
|
||||||
@@ -299,8 +279,8 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getSuccessPath(c: Context) {
|
private getSafeUrl(c: Context, path: string) {
|
||||||
const p = (this.config.cookie.pathSuccess ?? "/").replace(/\/+$/, "/");
|
const p = path.replace(/\/+$/, "/");
|
||||||
|
|
||||||
// nextjs doesn't support non-fq urls
|
// nextjs doesn't support non-fq urls
|
||||||
// but env could be proxied (stackblitz), so we shouldn't fq every url
|
// but env could be proxied (stackblitz), so we shouldn't fq every url
|
||||||
@@ -312,21 +292,26 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
||||||
if (this.isJsonRequest(c)) {
|
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
|
||||||
return c.json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const successUrl = this.getSuccessPath(c);
|
|
||||||
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
||||||
//console.log("auth respond", { redirect, successUrl, successPath });
|
//console.log("auth respond", { redirect, successUrl, successPath });
|
||||||
|
|
||||||
if ("token" in data) {
|
if ("token" in data) {
|
||||||
await this.setAuthCookie(c, data.token);
|
await this.setAuthCookie(c, data.token);
|
||||||
|
|
||||||
|
if (this.isJsonRequest(c)) {
|
||||||
|
return c.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
// can't navigate to "/" – doesn't work on nextjs
|
// can't navigate to "/" – doesn't work on nextjs
|
||||||
//console.log("auth success, redirecting to", successUrl);
|
//console.log("auth success, redirecting to", successUrl);
|
||||||
return c.redirect(successUrl);
|
return c.redirect(successUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isJsonRequest(c)) {
|
||||||
|
return c.json(data, 400);
|
||||||
|
}
|
||||||
|
|
||||||
let message = "An error occured";
|
let message = "An error occured";
|
||||||
if (data instanceof Exception) {
|
if (data instanceof Exception) {
|
||||||
message = data.message;
|
message = data.message;
|
||||||
@@ -348,8 +333,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
await this.verify(token);
|
return await this.verify(token);
|
||||||
return this.getUser();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Authenticator, Strategy } from "auth";
|
import type { Authenticator, Strategy } from "auth";
|
||||||
|
import { isDebug, tbValidator as tb } from "core";
|
||||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
import { type Static, StringEnum, Type, parse } from "core/utils";
|
||||||
import { hash } from "core/utils";
|
import { hash } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { type Context, Hono } from "hono";
|
||||||
@@ -56,26 +57,56 @@ export class PasswordStrategy implements Strategy {
|
|||||||
const hono = new Hono();
|
const hono = new Hono();
|
||||||
|
|
||||||
return hono
|
return hono
|
||||||
.post("/login", async (c) => {
|
.post(
|
||||||
|
"/login",
|
||||||
|
tb(
|
||||||
|
"query",
|
||||||
|
Type.Object({
|
||||||
|
redirect: Type.Optional(Type.String())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
const body = await authenticator.getBody(c);
|
const body = await authenticator.getBody(c);
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await this.login(body);
|
const payload = await this.login(body);
|
||||||
const data = await authenticator.resolve("login", this, payload.password, payload);
|
const data = await authenticator.resolve(
|
||||||
|
"login",
|
||||||
|
this,
|
||||||
|
payload.password,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
return await authenticator.respond(c, data);
|
return await authenticator.respond(c, data, redirect);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return await authenticator.respond(c, e);
|
return await authenticator.respond(c, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/register",
|
||||||
|
tb(
|
||||||
|
"query",
|
||||||
|
Type.Object({
|
||||||
|
redirect: Type.Optional(Type.String())
|
||||||
})
|
})
|
||||||
.post("/register", async (c) => {
|
),
|
||||||
|
async (c) => {
|
||||||
const body = await authenticator.getBody(c);
|
const body = await authenticator.getBody(c);
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
const payload = await this.register(body);
|
const payload = await this.register(body);
|
||||||
const data = await authenticator.resolve("register", this, payload.password, payload);
|
const data = await authenticator.resolve(
|
||||||
|
"register",
|
||||||
|
this,
|
||||||
|
payload.password,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
return await authenticator.respond(c, data);
|
return await authenticator.respond(c, data, redirect);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getActions(): StrategyActions {
|
getActions(): StrategyActions {
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { Exception, Permission } from "core";
|
import { Exception, Permission } from "core";
|
||||||
import { objectTransform } from "core/utils";
|
import { objectTransform } from "core/utils";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import type { ServerEnv } from "modules/Module";
|
||||||
import { Role } from "./Role";
|
import { Role } from "./Role";
|
||||||
|
|
||||||
export type GuardUserContext = {
|
export type GuardUserContext = {
|
||||||
role: string | null | undefined;
|
role?: string | null;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GuardConfig = {
|
export type GuardConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||||
|
|
||||||
const debug = false;
|
const debug = false;
|
||||||
|
|
||||||
export class Guard {
|
export class Guard {
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
user?: GuardUserContext;
|
|
||||||
roles?: Role[];
|
roles?: Role[];
|
||||||
config?: GuardConfig;
|
config?: GuardConfig;
|
||||||
|
|
||||||
@@ -89,24 +91,19 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUserContext(user: GuardUserContext | undefined) {
|
getUserRole(user?: GuardUserContext): Role | undefined {
|
||||||
this.user = user;
|
if (user && typeof user.role === "string") {
|
||||||
return this;
|
const role = this.roles?.find((role) => role.name === user?.role);
|
||||||
}
|
|
||||||
|
|
||||||
getUserRole(): Role | undefined {
|
|
||||||
if (this.user && typeof this.user.role === "string") {
|
|
||||||
const role = this.roles?.find((role) => role.name === this.user?.role);
|
|
||||||
if (role) {
|
if (role) {
|
||||||
debug && console.log("guard: role found", [this.user.role]);
|
debug && console.log("guard: role found", [user.role]);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug &&
|
debug &&
|
||||||
console.log("guard: role not found", {
|
console.log("guard: role not found", {
|
||||||
user: this.user,
|
user: user,
|
||||||
role: this.user?.role
|
role: user?.role
|
||||||
});
|
});
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
@@ -119,9 +116,9 @@ export class Guard {
|
|||||||
return this.config?.enabled === true;
|
return this.config?.enabled === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPermission(permission: Permission): boolean;
|
hasPermission(permission: Permission, user?: GuardUserContext): boolean;
|
||||||
hasPermission(name: string): boolean;
|
hasPermission(name: string, user?: GuardUserContext): boolean;
|
||||||
hasPermission(permissionOrName: Permission | string): boolean {
|
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
||||||
if (!this.isEnabled()) {
|
if (!this.isEnabled()) {
|
||||||
//console.log("guard not enabled, allowing");
|
//console.log("guard not enabled, allowing");
|
||||||
return true;
|
return true;
|
||||||
@@ -133,7 +130,7 @@ export class Guard {
|
|||||||
throw new Error(`Permission ${name} does not exist`);
|
throw new Error(`Permission ${name} does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = this.getUserRole();
|
const role = this.getUserRole(user);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
debug && console.log("guard: role not found, denying");
|
debug && console.log("guard: role not found, denying");
|
||||||
@@ -156,12 +153,13 @@ export class Guard {
|
|||||||
return !!rolePermission;
|
return !!rolePermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
granted(permission: Permission | string): boolean {
|
granted(permission: Permission | string, c?: GuardContext): boolean {
|
||||||
return this.hasPermission(permission as any);
|
const user = c && "get" in c ? c.get("auth")?.user : c;
|
||||||
|
return this.hasPermission(permission as any, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
throwUnlessGranted(permission: Permission | string) {
|
throwUnlessGranted(permission: Permission | string, c: GuardContext) {
|
||||||
if (!this.granted(permission)) {
|
if (!this.granted(permission, c)) {
|
||||||
throw new Exception(
|
throw new Exception(
|
||||||
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
`Permission "${typeof permission === "string" ? permission : permission.name}" not granted`,
|
||||||
403
|
403
|
||||||
|
|||||||
+33
-23
@@ -10,7 +10,12 @@ function getPath(reqOrCtx: Request | Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
||||||
if (c.get("auth_skip")) return true;
|
const authCtx = c.get("auth");
|
||||||
|
if (!authCtx) {
|
||||||
|
throw new Error("auth ctx not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authCtx.skip) return true;
|
||||||
|
|
||||||
const req = c.req.raw;
|
const req = c.req.raw;
|
||||||
if (!skip) return false;
|
if (!skip) return false;
|
||||||
@@ -18,7 +23,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
|||||||
const path = getPath(req);
|
const path = getPath(req);
|
||||||
const result = skip.some((s) => patternMatch(path, s));
|
const result = skip.some((s) => patternMatch(path, s));
|
||||||
|
|
||||||
c.set("auth_skip", result);
|
authCtx.skip = result;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,29 +31,31 @@ export const auth = (options?: {
|
|||||||
skip?: (string | RegExp)[];
|
skip?: (string | RegExp)[];
|
||||||
}) =>
|
}) =>
|
||||||
createMiddleware<ServerEnv>(async (c, next) => {
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
|
if (!c.get("auth")) {
|
||||||
|
c.set("auth", {
|
||||||
|
registered: false,
|
||||||
|
resolved: false,
|
||||||
|
skip: false,
|
||||||
|
user: undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const app = c.get("app");
|
const app = c.get("app");
|
||||||
const guard = app?.modules.ctx().guard;
|
const authCtx = c.get("auth")!;
|
||||||
const authenticator = app?.module.auth.authenticator;
|
const authenticator = app?.module.auth.authenticator;
|
||||||
|
|
||||||
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
let skipped = shouldSkip(c, options?.skip) || !app?.module.auth.enabled;
|
||||||
|
|
||||||
// make sure to only register once
|
// make sure to only register once
|
||||||
if (c.get("auth_registered")) {
|
if (authCtx.registered) {
|
||||||
skipped = true;
|
skipped = true;
|
||||||
console.warn(`auth middleware already registered for ${getPath(c)}`);
|
console.warn(`auth middleware already registered for ${getPath(c)}`);
|
||||||
} else {
|
} else {
|
||||||
c.set("auth_registered", true);
|
authCtx.registered = true;
|
||||||
|
|
||||||
if (!skipped) {
|
if (!skipped && !authCtx.resolved && app?.module.auth.enabled) {
|
||||||
const resolved = c.get("auth_resolved");
|
authCtx.user = await authenticator?.resolveAuthFromRequest(c);
|
||||||
if (!resolved) {
|
authCtx.resolved = true;
|
||||||
if (!app?.module.auth.enabled) {
|
|
||||||
guard?.setUserContext(undefined);
|
|
||||||
} else {
|
|
||||||
guard?.setUserContext(await authenticator?.resolveAuthFromRequest(c));
|
|
||||||
c.set("auth_resolved", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +67,9 @@ export const auth = (options?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// release
|
// release
|
||||||
guard?.setUserContext(undefined);
|
authCtx.skip = false;
|
||||||
authenticator?.resetUser();
|
authCtx.resolved = false;
|
||||||
c.set("auth_resolved", false);
|
authCtx.user = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const permission = (
|
export const permission = (
|
||||||
@@ -75,23 +82,26 @@ export const permission = (
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
createMiddleware<ServerEnv>(async (c, next) => {
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
const app = c.get("app");
|
const app = c.get("app");
|
||||||
//console.log("skip?", c.get("auth_skip"));
|
const authCtx = c.get("auth");
|
||||||
|
if (!authCtx) {
|
||||||
|
throw new Error("auth ctx not found");
|
||||||
|
}
|
||||||
|
|
||||||
// in tests, app is not defined
|
// in tests, app is not defined
|
||||||
if (!c.get("auth_registered") || !app) {
|
if (!authCtx.registered || !app) {
|
||||||
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
const msg = `auth middleware not registered, cannot check permissions for ${getPath(c)}`;
|
||||||
if (app?.module.auth.enabled) {
|
if (app?.module.auth.enabled) {
|
||||||
throw new Error(msg);
|
throw new Error(msg);
|
||||||
} else {
|
} else {
|
||||||
console.warn(msg);
|
console.warn(msg);
|
||||||
}
|
}
|
||||||
} else if (!c.get("auth_skip")) {
|
} else if (!authCtx.skip) {
|
||||||
const guard = app.modules.ctx().guard;
|
const guard = app.modules.ctx().guard;
|
||||||
const permissions = Array.isArray(permission) ? permission : [permission];
|
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||||
|
|
||||||
if (options?.onGranted || options?.onDenied) {
|
if (options?.onGranted || options?.onDenied) {
|
||||||
let returned: undefined | void | Response;
|
let returned: undefined | void | Response;
|
||||||
if (permissions.every((p) => guard.granted(p))) {
|
if (permissions.every((p) => guard.granted(p, c))) {
|
||||||
returned = await options?.onGranted?.(c);
|
returned = await options?.onGranted?.(c);
|
||||||
} else {
|
} else {
|
||||||
returned = await options?.onDenied?.(c);
|
returned = await options?.onDenied?.(c);
|
||||||
@@ -100,7 +110,7 @@ export const permission = (
|
|||||||
return returned;
|
return returned;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
permissions.some((p) => guard.throwUnlessGranted(p));
|
permissions.some((p) => guard.throwUnlessGranted(p, c));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +1,23 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import url from "node:url";
|
import url from "node:url";
|
||||||
|
import { createApp } from "App";
|
||||||
|
import { getConnectionCredentialsFromEnv } from "cli/commands/run/platform";
|
||||||
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
|
import { getDistPath, getRelativeDistPath, getRootPath } from "cli/utils/sys";
|
||||||
|
import { Argument } from "commander";
|
||||||
|
import { showRoutes } from "hono/dev";
|
||||||
import type { CliCommand } from "../types";
|
import type { CliCommand } from "../types";
|
||||||
|
|
||||||
export const debug: CliCommand = (program) => {
|
export const debug: CliCommand = (program) => {
|
||||||
program
|
program
|
||||||
.command("debug")
|
.command("debug")
|
||||||
.description("debug path resolution")
|
.description("debug bknd")
|
||||||
.action(() => {
|
.addArgument(new Argument("<subject>", "subject to debug").choices(Object.keys(subjects)))
|
||||||
console.log("paths", {
|
.action(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
const subjects = {
|
||||||
|
paths: async () => {
|
||||||
|
console.log("[PATHS]", {
|
||||||
rootpath: getRootPath(),
|
rootpath: getRootPath(),
|
||||||
distPath: getDistPath(),
|
distPath: getDistPath(),
|
||||||
relativeDistPath: getRelativeDistPath(),
|
relativeDistPath: getRelativeDistPath(),
|
||||||
@@ -16,5 +25,20 @@ export const debug: CliCommand = (program) => {
|
|||||||
dir: path.dirname(url.fileURLToPath(import.meta.url)),
|
dir: path.dirname(url.fileURLToPath(import.meta.url)),
|
||||||
resolvedPkg: path.resolve(getRootPath(), "package.json")
|
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 { run } from "./run";
|
||||||
export { debug } from "./debug";
|
export { debug } from "./debug";
|
||||||
export { user } from "./user";
|
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 }) {
|
export async function startServer(server: Platform, app: any, options: { port: number }) {
|
||||||
const port = options.port;
|
const port = options.port;
|
||||||
console.log(`(using ${server} serve)`);
|
console.log(`Using ${server} serve`);
|
||||||
|
|
||||||
switch (server) {
|
switch (server) {
|
||||||
case "node": {
|
case "node": {
|
||||||
@@ -54,7 +54,7 @@ export async function startServer(server: Platform, app: any, options: { port: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = `http://localhost:${port}`;
|
const url = `http://localhost:${port}`;
|
||||||
console.log(`Server listening on ${url}`);
|
console.info("Server listening on", url);
|
||||||
await open(url);
|
await open(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,3 +76,9 @@ export async function getConfigPath(filePath?: string) {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getConnectionCredentialsFromEnv() {
|
||||||
|
const dbUrl = process.env.DB_URL;
|
||||||
|
const dbToken = process.env.DB_TOKEN;
|
||||||
|
return dbUrl ? { url: dbUrl, authToken: dbToken } : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ import { App, type CreateAppConfig } from "App";
|
|||||||
import { StorageLocalAdapter } from "adapter/node";
|
import { StorageLocalAdapter } from "adapter/node";
|
||||||
import type { CliBkndConfig, CliCommand } from "cli/types";
|
import type { CliBkndConfig, CliCommand } from "cli/types";
|
||||||
import { Option } from "commander";
|
import { Option } from "commander";
|
||||||
import { config } from "core";
|
import { colorizeConsole, config } from "core";
|
||||||
|
import dotenv from "dotenv";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
|
import c from "picocolors";
|
||||||
import {
|
import {
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
type Platform,
|
type Platform,
|
||||||
attachServeStatic,
|
attachServeStatic,
|
||||||
getConfigPath,
|
getConfigPath,
|
||||||
|
getConnectionCredentialsFromEnv,
|
||||||
startServer
|
startServer
|
||||||
} from "./platform";
|
} from "./platform";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
const isBun = typeof Bun !== "undefined";
|
const isBun = typeof Bun !== "undefined";
|
||||||
|
|
||||||
export const run: CliCommand = (program) => {
|
export const run: CliCommand = (program) => {
|
||||||
@@ -24,6 +28,13 @@ export const run: CliCommand = (program) => {
|
|||||||
.default(config.server.default_port)
|
.default(config.server.default_port)
|
||||||
.argParser((v) => Number.parseInt(v))
|
.argParser((v) => Number.parseInt(v))
|
||||||
)
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option("-m, --memory", "use in-memory database").conflicts([
|
||||||
|
"config",
|
||||||
|
"db-url",
|
||||||
|
"db-token"
|
||||||
|
])
|
||||||
|
)
|
||||||
.addOption(new Option("-c, --config <config>", "config file"))
|
.addOption(new Option("-c, --config <config>", "config file"))
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
|
new Option("--db-url <db>", "database url, can be any valid libsql url").conflicts(
|
||||||
@@ -94,23 +105,44 @@ export async function makeConfigApp(config: CliBkndConfig, platform?: Platform)
|
|||||||
|
|
||||||
async function action(options: {
|
async function action(options: {
|
||||||
port: number;
|
port: number;
|
||||||
|
memory?: boolean;
|
||||||
config?: string;
|
config?: string;
|
||||||
dbUrl?: string;
|
dbUrl?: string;
|
||||||
dbToken?: string;
|
dbToken?: string;
|
||||||
server: Platform;
|
server: Platform;
|
||||||
}) {
|
}) {
|
||||||
|
colorizeConsole(console);
|
||||||
const configFilePath = await getConfigPath(options.config);
|
const configFilePath = await getConfigPath(options.config);
|
||||||
|
|
||||||
let app: App;
|
let app: App | undefined = undefined;
|
||||||
if (options.dbUrl || !configFilePath) {
|
if (options.dbUrl) {
|
||||||
|
console.info("Using connection from", c.cyan("--db-url"));
|
||||||
const connection = options.dbUrl
|
const connection = options.dbUrl
|
||||||
? { type: "libsql" as const, config: { url: options.dbUrl, authToken: options.dbToken } }
|
? { url: options.dbUrl, authToken: options.dbToken }
|
||||||
: undefined;
|
: undefined;
|
||||||
app = await makeApp({ connection, server: { platform: options.server } });
|
app = await makeApp({ connection, server: { platform: options.server } });
|
||||||
} else {
|
} else if (configFilePath) {
|
||||||
console.log("Using config from:", configFilePath);
|
console.info("Using config from", c.cyan(configFilePath));
|
||||||
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
const config = (await import(configFilePath).then((m) => m.default)) as CliBkndConfig;
|
||||||
app = await makeConfigApp(config, options.server);
|
app = await makeConfigApp(config, options.server);
|
||||||
|
} else if (options.memory) {
|
||||||
|
console.info("Using", c.cyan("in-memory"), "connection");
|
||||||
|
app = await makeApp({ server: { platform: options.server } });
|
||||||
|
} else {
|
||||||
|
const credentials = getConnectionCredentialsFromEnv();
|
||||||
|
if (credentials) {
|
||||||
|
console.info("Using connection from env", c.cyan(credentials.url));
|
||||||
|
app = await makeConfigApp({ app: { connection: credentials } }, options.server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
const connection = { url: "file:data.db" } as Config;
|
||||||
|
console.info("Using connection", c.cyan(connection.url));
|
||||||
|
app = await makeApp({
|
||||||
|
connection,
|
||||||
|
server: { platform: options.server }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await startServer(options.server, app, { port: options.port });
|
await startServer(options.server, app, { port: options.port });
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
|
import color from "picocolors";
|
||||||
import * as commands from "./commands";
|
import * as commands from "./commands";
|
||||||
import { getVersion } from "./utils/sys";
|
import { getVersion } from "./utils/sys";
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
|
const version = await getVersion();
|
||||||
program
|
program
|
||||||
.name("bknd")
|
.name("bknd")
|
||||||
.description("bknd cli")
|
.description(color.yellowBright("⚡") + " bknd cli " + color.bold(color.cyan(`v${version}`)))
|
||||||
.version(await getVersion());
|
.version(version);
|
||||||
|
|
||||||
// register commands
|
// register commands
|
||||||
for (const command of Object.values(commands)) {
|
for (const command of Object.values(commands)) {
|
||||||
|
|||||||
@@ -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 { readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import url from "node:url";
|
import url from "node:url";
|
||||||
@@ -16,9 +17,9 @@ export function getRelativeDistPath() {
|
|||||||
return path.relative(process.cwd(), getDistPath());
|
return path.relative(process.cwd(), getDistPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getVersion() {
|
export async function getVersion(_path: string = "") {
|
||||||
try {
|
try {
|
||||||
const resolved = path.resolve(getRootPath(), "package.json");
|
const resolved = path.resolve(getRootPath(), path.join(_path, "package.json"));
|
||||||
const pkg = await readFile(resolved, "utf-8");
|
const pkg = await readFile(resolved, "utf-8");
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
return JSON.parse(pkg).version ?? "preview";
|
return JSON.parse(pkg).version ?? "preview";
|
||||||
@@ -38,3 +39,35 @@ export async function fileExists(filePath: string) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exec(command: string, opts?: { silent?: boolean; env?: Record<string, string> }) {
|
||||||
|
const stdio = opts?.silent ? "pipe" : "inherit";
|
||||||
|
const output = execSync(command, {
|
||||||
|
stdio: ["inherit", stdio, stdio],
|
||||||
|
env: { ...process.env, ...opts?.env }
|
||||||
|
});
|
||||||
|
if (!opts?.silent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return output.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function execAsync(
|
||||||
|
command: string,
|
||||||
|
opts?: { silent?: boolean; env?: Record<string, string> }
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
nodeExec(
|
||||||
|
command,
|
||||||
|
{
|
||||||
|
env: { ...process.env, ...opts?.env }
|
||||||
|
},
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
event: EventClass;
|
||||||
handler: ListenerHandler<E>;
|
handler: ListenerHandler<E>;
|
||||||
once: boolean = false;
|
once: boolean = false;
|
||||||
|
id?: string;
|
||||||
|
|
||||||
constructor(event: EventClass, handler: ListenerHandler<E>, mode: ListenerMode = "async") {
|
constructor(
|
||||||
|
event: EventClass,
|
||||||
|
handler: ListenerHandler<E>,
|
||||||
|
mode: ListenerMode = "async",
|
||||||
|
id?: string
|
||||||
|
) {
|
||||||
this.event = event;
|
this.event = event;
|
||||||
this.handler = handler;
|
this.handler = handler;
|
||||||
this.mode = mode;
|
this.mode = mode;
|
||||||
|
this.id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type RegisterListenerConfig =
|
|||||||
| {
|
| {
|
||||||
mode?: ListenerMode;
|
mode?: ListenerMode;
|
||||||
once?: boolean;
|
once?: boolean;
|
||||||
|
id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface EmitsEvents {
|
export interface EmitsEvents {
|
||||||
@@ -124,6 +125,15 @@ export class EventManager<
|
|||||||
addListener(listener: EventListener) {
|
addListener(listener: EventListener) {
|
||||||
this.throwIfEventNotRegistered(listener.event);
|
this.throwIfEventNotRegistered(listener.event);
|
||||||
|
|
||||||
|
if (listener.id) {
|
||||||
|
const existing = this.listeners.find((l) => l.id === listener.id);
|
||||||
|
if (existing) {
|
||||||
|
// @todo: add a verbose option?
|
||||||
|
//console.warn(`Listener with id "${listener.id}" already exists.`);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.listeners.push(listener);
|
this.listeners.push(listener);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -140,6 +150,9 @@ export class EventManager<
|
|||||||
if (config.once) {
|
if (config.once) {
|
||||||
listener.once = true;
|
listener.once = true;
|
||||||
}
|
}
|
||||||
|
if (config.id) {
|
||||||
|
listener.id = `${event.slug}-${config.id}`;
|
||||||
|
}
|
||||||
this.addListener(listener as any);
|
this.addListener(listener as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export {
|
|||||||
} from "./object/query/query";
|
} from "./object/query/query";
|
||||||
export { Registry, type Constructor } from "./registry/Registry";
|
export { Registry, type Constructor } from "./registry/Registry";
|
||||||
|
|
||||||
|
export * from "./console";
|
||||||
|
|
||||||
// compatibility
|
// compatibility
|
||||||
export type Middleware = MiddlewareHandler<any, any, any>;
|
export type Middleware = MiddlewareHandler<any, any, any>;
|
||||||
export interface ClassController {
|
export interface ClassController {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { cloneDeep, get, has, mergeWith, omit, set } from "lodash-es";
|
import { get, has, omit, set } from "lodash-es";
|
||||||
import {
|
import {
|
||||||
Default,
|
Default,
|
||||||
type Static,
|
type Static,
|
||||||
type TObject,
|
type TObject,
|
||||||
getFullPathKeys,
|
getFullPathKeys,
|
||||||
mark,
|
mergeObjectWith,
|
||||||
parse,
|
parse,
|
||||||
stripMark
|
stripMark
|
||||||
} from "../utils";
|
} from "../utils";
|
||||||
@@ -33,7 +33,7 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
) {
|
) {
|
||||||
this._default = Default(_schema, {} as any) as any;
|
this._default = Default(_schema, {} as any) as any;
|
||||||
this._value = initial
|
this._value = initial
|
||||||
? parse(_schema, cloneDeep(initial as any), {
|
? parse(_schema, structuredClone(initial as any), {
|
||||||
forceParse: this.isForceParse(),
|
forceParse: this.isForceParse(),
|
||||||
skipMark: this.isForceParse()
|
skipMark: this.isForceParse()
|
||||||
})
|
})
|
||||||
@@ -64,8 +64,12 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
return this._config;
|
return this._config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clone() {
|
||||||
|
return structuredClone(this._config);
|
||||||
|
}
|
||||||
|
|
||||||
async set(config: Static<Schema>, noEmit?: boolean): Promise<Static<Schema>> {
|
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,
|
forceParse: true,
|
||||||
skipMark: this.isForceParse()
|
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>]> {
|
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;
|
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||||
|
|
||||||
this.throwIfRestricted(partial);
|
this.throwIfRestricted(partial);
|
||||||
@@ -122,11 +126,13 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
|
|
||||||
// overwrite arrays and primitives, only deep merge objects
|
// overwrite arrays and primitives, only deep merge objects
|
||||||
// @ts-ignore
|
// @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)) {
|
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
||||||
return srcValue;
|
return srcValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
//console.log("---new", _jsonp(config));
|
||||||
|
|
||||||
//console.log("overwritePaths", this.options?.overwritePaths);
|
//console.log("overwritePaths", this.options?.overwritePaths);
|
||||||
if (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);
|
const newConfig = await this.set(config);
|
||||||
return [partial, newConfig];
|
return [partial, newConfig];
|
||||||
}
|
}
|
||||||
|
|
||||||
async overwrite(path: string, value: any): Promise<[Partial<Static<Schema>>, Static<Schema>]> {
|
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;
|
const partial = path.length > 0 ? (set({}, path, value) as Partial<Static<Schema>>) : value;
|
||||||
|
|
||||||
this.throwIfRestricted(partial);
|
this.throwIfRestricted(partial);
|
||||||
@@ -192,7 +198,7 @@ export class SchemaObject<Schema extends TObject> {
|
|||||||
if (p.length > 1) {
|
if (p.length > 1) {
|
||||||
const parent = p.slice(0, -1).join(".");
|
const parent = p.slice(0, -1).join(".");
|
||||||
if (!has(this._config, parent)) {
|
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`);
|
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`);
|
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 removed = get(current, path) as Partial<Static<Schema>>;
|
||||||
const config = omit(current, path);
|
const config = omit(current, path);
|
||||||
const newConfig = await this.set(config);
|
const newConfig = await this.set(config);
|
||||||
|
|||||||
@@ -4,6 +4,28 @@ export function _jsonp(obj: any, space = 2): string {
|
|||||||
return JSON.stringify(obj, null, space);
|
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 {
|
export function safelyParseObjectValues<T extends { [key: string]: any }>(obj: T): T {
|
||||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||||
try {
|
try {
|
||||||
@@ -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.
|
* Deep merge two objects.
|
||||||
* @param target
|
* @param target
|
||||||
@@ -197,3 +210,152 @@ export function objectCleanEmpty<Obj extends { [key: string]: any }>(obj: Obj):
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as any);
|
}, {} 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> {
|
export function headersToObject(headers: Headers): Record<string, string> {
|
||||||
if (!headers) return {};
|
if (!headers) return {};
|
||||||
return { ...Object.fromEntries(headers.entries()) };
|
return { ...Object.fromEntries(headers.entries()) };
|
||||||
@@ -82,3 +86,259 @@ export function decodeSearch(str) {
|
|||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isReadableStream(value: unknown): value is ReadableStream {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as ReadableStream).getReader === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlob(value: unknown): value is Blob {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
typeof (value as Blob).arrayBuffer === "function" &&
|
||||||
|
typeof (value as Blob).type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFile(value: unknown): value is File {
|
||||||
|
return (
|
||||||
|
isBlob(value) &&
|
||||||
|
typeof (value as File).name === "string" &&
|
||||||
|
typeof (value as File).lastModified === "number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArrayBuffer(value: unknown): value is ArrayBuffer {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
Object.prototype.toString.call(value) === "[object ArrayBuffer]"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArrayBufferView(value: unknown): value is ArrayBufferView {
|
||||||
|
return typeof value === "object" && value !== null && ArrayBuffer.isView(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getContentName(request: Request): string | undefined;
|
||||||
|
export function getContentName(contentDisposition: string): string | undefined;
|
||||||
|
export function getContentName(headers: Headers): string | undefined;
|
||||||
|
export function getContentName(ctx: Headers | Request | string): string | undefined {
|
||||||
|
let c: string = "";
|
||||||
|
|
||||||
|
if (typeof ctx === "string") {
|
||||||
|
c = ctx;
|
||||||
|
} else if (ctx instanceof Headers) {
|
||||||
|
c = ctx.get("Content-Disposition") || "";
|
||||||
|
} else if (ctx instanceof Request) {
|
||||||
|
c = ctx.headers.get("Content-Disposition") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = c.match(/filename\*?=(?:UTF-8'')?("?)([^";]+)\1/);
|
||||||
|
return match ? match[2] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_SIGNATURES: Record<string, string> = {
|
||||||
|
"89504E47": "image/png",
|
||||||
|
FFD8FF: "image/jpeg",
|
||||||
|
"47494638": "image/gif",
|
||||||
|
"49492A00": "image/tiff", // Little Endian TIFF
|
||||||
|
"4D4D002A": "image/tiff", // Big Endian TIFF
|
||||||
|
"52494646????57454250": "image/webp", // WEBP (RIFF....WEBP)
|
||||||
|
"504B0304": "application/zip",
|
||||||
|
"25504446": "application/pdf",
|
||||||
|
"00000020667479706D70": "video/mp4",
|
||||||
|
"000001BA": "video/mpeg",
|
||||||
|
"000001B3": "video/mpeg",
|
||||||
|
"1A45DFA3": "video/webm",
|
||||||
|
"4F676753": "audio/ogg",
|
||||||
|
"494433": "audio/mpeg", // MP3 with ID3 header
|
||||||
|
FFF1: "audio/aac",
|
||||||
|
FFF9: "audio/aac",
|
||||||
|
"52494646????41564920": "audio/wav",
|
||||||
|
"52494646????57415645": "audio/wave",
|
||||||
|
"52494646????415550": "audio/aiff"
|
||||||
|
};
|
||||||
|
|
||||||
|
async function detectMimeType(
|
||||||
|
input: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | File | null
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
let buffer: Uint8Array;
|
||||||
|
|
||||||
|
if (isReadableStream(input)) {
|
||||||
|
const reader = input.getReader();
|
||||||
|
const { value } = await reader.read();
|
||||||
|
if (!value) return;
|
||||||
|
buffer = new Uint8Array(value);
|
||||||
|
} else if (isBlob(input) || isFile(input)) {
|
||||||
|
buffer = new Uint8Array(await input.slice(0, 12).arrayBuffer());
|
||||||
|
} else if (isArrayBuffer(input)) {
|
||||||
|
buffer = new Uint8Array(input);
|
||||||
|
} else if (isArrayBufferView(input)) {
|
||||||
|
buffer = new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
||||||
|
} else if (typeof input === "string") {
|
||||||
|
buffer = new TextEncoder().encode(input);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = Array.from(buffer.slice(0, 12))
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
for (const [signature, mime] of Object.entries(FILE_SIGNATURES)) {
|
||||||
|
const regex = new RegExp("^" + signature.replace(/\?\?/g, ".."));
|
||||||
|
if (regex.test(hex)) return mime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function blobToFile(
|
||||||
|
blob: Blob | File | unknown,
|
||||||
|
overrides: FilePropertyBag & { name?: string } = {}
|
||||||
|
): Promise<File> {
|
||||||
|
if (isFile(blob)) return blob;
|
||||||
|
if (!isBlob(blob)) throw new Error("Not a Blob");
|
||||||
|
|
||||||
|
const type = isMimeType(overrides.type, ["application/octet-stream"])
|
||||||
|
? overrides.type
|
||||||
|
: await detectMimeType(blob);
|
||||||
|
const ext = type ? extension(type) : "";
|
||||||
|
const name = overrides.name || [randomString(16), ext].filter(Boolean).join(".");
|
||||||
|
|
||||||
|
return new File([blob], name, {
|
||||||
|
type: type || guess(name),
|
||||||
|
lastModified: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFileFromContext(c: Context<any>): Promise<File> {
|
||||||
|
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType?.startsWith("multipart/form-data") ||
|
||||||
|
contentType?.startsWith("application/x-www-form-urlencoded")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const f = await c.req.formData();
|
||||||
|
if ([...f.values()].length > 0) {
|
||||||
|
const v = [...f.values()][0];
|
||||||
|
return await blobToFile(v);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Error parsing form data", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const blob = await c.req.blob();
|
||||||
|
if (isFile(blob)) {
|
||||||
|
return blob;
|
||||||
|
} else if (isBlob(blob)) {
|
||||||
|
return await blobToFile(blob, { name: getContentName(c.req.raw), type: contentType });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Error parsing blob", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No file found in request");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBodyFromContext(c: Context<any>): Promise<ReadableStream | File> {
|
||||||
|
const contentType = c.req.header("Content-Type") ?? "application/octet-stream";
|
||||||
|
|
||||||
|
if (
|
||||||
|
!contentType?.startsWith("multipart/form-data") &&
|
||||||
|
!contentType?.startsWith("application/x-www-form-urlencoded")
|
||||||
|
) {
|
||||||
|
const body = c.req.raw.body;
|
||||||
|
if (body) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFileFromContext(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||||
|
// biome-ignore lint/suspicious/noConstEnum: <explanation>
|
||||||
|
export const enum HttpStatus {
|
||||||
|
// Informational responses (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":
|
case "SCREAMING_SNAKE_CASE":
|
||||||
return snakeToPascalWithSpaces(str.toLowerCase());
|
return snakeToPascalWithSpaces(str.toLowerCase());
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return str;
|
return ucFirst(str);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export function autoFormatString(str: string) {
|
||||||
|
return identifierToHumanReadable(str);
|
||||||
|
}
|
||||||
|
|
||||||
export function kebabToPascalWithSpaces(str: string): string {
|
export function kebabToPascalWithSpaces(str: string): string {
|
||||||
return str.split("-").map(ucFirst).join(" ");
|
return str.split("-").map(ucFirst).join(" ");
|
||||||
|
|||||||
@@ -42,3 +42,21 @@ export function enableConsoleLog() {
|
|||||||
console[severity as ConsoleSeverity] = fn;
|
console[severity as ConsoleSeverity] = fn;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function tryit(fn: () => void, fallback?: any) {
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} catch (e) {
|
||||||
|
return fallback || e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMemoryUsage() {
|
||||||
|
const usage = process.memoryUsage();
|
||||||
|
return {
|
||||||
|
rss: usage.rss / 1024 / 1024,
|
||||||
|
heapUsed: usage.heapUsed / 1024 / 1024,
|
||||||
|
external: usage.external / 1024 / 1024,
|
||||||
|
arrayBuffers: usage.arrayBuffers / 1024 / 1024
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
type ValueErrorIterator
|
type ValueErrorIterator
|
||||||
} from "@sinclair/typebox/errors";
|
} from "@sinclair/typebox/errors";
|
||||||
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
import { Check, Default, Value, type ValueError } from "@sinclair/typebox/value";
|
||||||
import { cloneDeep } from "lodash-es";
|
|
||||||
|
|
||||||
export type RecursivePartial<T> = {
|
export type RecursivePartial<T> = {
|
||||||
[P in keyof T]?: T[P] extends (infer U)[]
|
[P in keyof T]?: T[P] extends (infer U)[]
|
||||||
@@ -73,7 +72,7 @@ export class TypeInvalidError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stripMark<O = any>(obj: O) {
|
export function stripMark<O = any>(obj: O) {
|
||||||
const newObj = cloneDeep(obj);
|
const newObj = structuredClone(obj);
|
||||||
mark(newObj, false);
|
mark(newObj, false);
|
||||||
return newObj as O;
|
return newObj as O;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-4
@@ -13,7 +13,15 @@ import { type AppDataConfig, dataConfigSchema } from "./data-schema";
|
|||||||
|
|
||||||
export class AppData extends Module<typeof dataConfigSchema> {
|
export class AppData extends Module<typeof dataConfigSchema> {
|
||||||
override async build() {
|
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);
|
return constructEntity(name, entityConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -21,14 +29,14 @@ export class AppData extends Module<typeof dataConfigSchema> {
|
|||||||
const name = typeof _e === "string" ? _e : _e.name;
|
const name = typeof _e === "string" ? _e : _e.name;
|
||||||
const entity = entities[name];
|
const entity = entities[name];
|
||||||
if (entity) return entity;
|
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)
|
constructRelation(relation, _entity)
|
||||||
);
|
);
|
||||||
|
|
||||||
const indices = transformObject(this.config.indices ?? {}, (index, name) => {
|
const indices = transformObject(_indices, (index, name) => {
|
||||||
const entity = _entity(index.entity)!;
|
const entity = _entity(index.entity)!;
|
||||||
const fields = index.fields.map((f) => entity.field(f)!);
|
const fields = index.fields.map((f) => entity.field(f)!);
|
||||||
return new EntityIndex(entity, fields, index.unique, name);
|
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.guard.registerPermissions(Object.values(DataPermissions));
|
||||||
|
|
||||||
|
this.ctx.logger.clear();
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -1,10 +1,10 @@
|
|||||||
import type { DB } from "core";
|
import type { DB } from "core";
|
||||||
import type { EntityData, RepoQuery, RepoQueryIn, RepositoryResponse } from "data";
|
import type { EntityData, RepoQueryIn, RepositoryResponse } from "data";
|
||||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules";
|
||||||
|
|
||||||
export type DataApiOptions = BaseModuleApiOptions & {
|
export type DataApiOptions = BaseModuleApiOptions & {
|
||||||
queryLengthLimit: number;
|
queryLengthLimit: number;
|
||||||
defaultQuery: Partial<RepoQuery>;
|
defaultQuery: Partial<RepoQueryIn>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class DataApi extends ModuleApi<DataApiOptions> {
|
export class DataApi extends ModuleApi<DataApiOptions> {
|
||||||
@@ -23,7 +23,10 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
id: PrimaryFieldType,
|
id: PrimaryFieldType,
|
||||||
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
|
query: Omit<RepoQueryIn, "where" | "limit" | "offset"> = {}
|
||||||
) {
|
) {
|
||||||
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>([entity as any, id], query);
|
return this.get<Pick<RepositoryResponse<Data>, "meta" | "data">>(
|
||||||
|
["entity", entity as any, id],
|
||||||
|
query
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
readMany<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||||
@@ -33,13 +36,13 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
type T = Pick<RepositoryResponse<Data[]>, "meta" | "data">;
|
||||||
|
|
||||||
const input = query ?? this.options.defaultQuery;
|
const input = query ?? this.options.defaultQuery;
|
||||||
const req = this.get<T>([entity as any], input);
|
const req = this.get<T>(["entity", entity as any], input);
|
||||||
|
|
||||||
if (req.request.url.length <= this.options.queryLengthLimit) {
|
if (req.request.url.length <= this.options.queryLengthLimit) {
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.post<T>([entity as any, "query"], input);
|
return this.post<T>(["entity", entity as any, "query"], input);
|
||||||
}
|
}
|
||||||
|
|
||||||
readManyByReference<
|
readManyByReference<
|
||||||
@@ -48,7 +51,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
Data = R extends keyof DB ? DB[R] : EntityData
|
Data = R extends keyof DB ? DB[R] : EntityData
|
||||||
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
>(entity: E, id: PrimaryFieldType, reference: R, query: RepoQueryIn = {}) {
|
||||||
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
return this.get<Pick<RepositoryResponse<Data[]>, "meta" | "data">>(
|
||||||
[entity as any, id, reference],
|
["entity", entity as any, id, reference],
|
||||||
query ?? this.options.defaultQuery
|
query ?? this.options.defaultQuery
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,7 +60,7 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
entity: E,
|
entity: E,
|
||||||
input: Omit<Data, "id">
|
input: Omit<Data, "id">
|
||||||
) {
|
) {
|
||||||
return this.post<RepositoryResponse<Data>>([entity as any], input);
|
return this.post<RepositoryResponse<Data>>(["entity", entity as any], input);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
updateOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||||
@@ -65,19 +68,19 @@ export class DataApi extends ModuleApi<DataApiOptions> {
|
|||||||
id: PrimaryFieldType,
|
id: PrimaryFieldType,
|
||||||
input: Partial<Omit<Data, "id">>
|
input: Partial<Omit<Data, "id">>
|
||||||
) {
|
) {
|
||||||
return this.patch<RepositoryResponse<Data>>([entity as any, id], input);
|
return this.patch<RepositoryResponse<Data>>(["entity", entity as any, id], input);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
deleteOne<E extends keyof DB | string, Data = E extends keyof DB ? DB[E] : EntityData>(
|
||||||
entity: E,
|
entity: E,
|
||||||
id: PrimaryFieldType
|
id: PrimaryFieldType
|
||||||
) {
|
) {
|
||||||
return this.delete<RepositoryResponse<Data>>([entity as any, id]);
|
return this.delete<RepositoryResponse<Data>>(["entity", entity as any, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
count<E extends keyof DB | string>(entity: E, where: RepoQuery["where"] = {}) {
|
count<E extends keyof DB | string>(entity: E, where: RepoQueryIn["where"] = {}) {
|
||||||
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
|
return this.post<RepositoryResponse<{ entity: E; count: number }>>(
|
||||||
[entity as any, "fn", "count"],
|
["entity", entity as any, "fn", "count"],
|
||||||
where
|
where
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,103 @@ export class DataController extends Controller {
|
|||||||
return c.json({ tables: tables.map((t) => t.name), changes });
|
return c.json({ tables: tables.map((t) => t.name), changes });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema endpoints
|
||||||
|
*/
|
||||||
|
hono
|
||||||
|
// read entity schema
|
||||||
|
.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
||||||
|
const $id = `${this.config.basepath}/schema.json`;
|
||||||
|
const schemas = Object.fromEntries(
|
||||||
|
this.em.entities.map((e) => [
|
||||||
|
e.name,
|
||||||
|
{
|
||||||
|
$ref: `${this.config.basepath}/schemas/${e.name}`
|
||||||
|
}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return c.json({
|
||||||
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
$id,
|
||||||
|
properties: schemas
|
||||||
|
});
|
||||||
|
})
|
||||||
|
// read schema
|
||||||
|
.get(
|
||||||
|
"/schemas/:entity/:context?",
|
||||||
|
permission(DataPermissions.entityRead),
|
||||||
|
tb(
|
||||||
|
"param",
|
||||||
|
Type.Object({
|
||||||
|
entity: Type.String(),
|
||||||
|
context: Type.Optional(StringEnum(["create", "update"]))
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
//console.log("request", c.req.raw);
|
||||||
|
const { entity, context } = c.req.param();
|
||||||
|
if (!this.entityExists(entity)) {
|
||||||
|
console.warn("not found:", entity, definedEntities);
|
||||||
|
return c.notFound();
|
||||||
|
}
|
||||||
|
const _entity = this.em.entity(entity);
|
||||||
|
const schema = _entity.toSchema({ context } as any);
|
||||||
|
const url = new URL(c.req.url);
|
||||||
|
const base = `${url.origin}${this.config.basepath}`;
|
||||||
|
const $id = `${this.config.basepath}/schemas/${entity}`;
|
||||||
|
return c.json({
|
||||||
|
$schema: `${base}/schema.json`,
|
||||||
|
$id,
|
||||||
|
title: _entity.label,
|
||||||
|
$comment: _entity.config.description,
|
||||||
|
...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
|
* Function endpoints
|
||||||
*/
|
*/
|
||||||
@@ -149,55 +246,6 @@ export class DataController extends Controller {
|
|||||||
* Read endpoints
|
* Read endpoints
|
||||||
*/
|
*/
|
||||||
hono
|
hono
|
||||||
// read entity schema
|
|
||||||
.get("/schema.json", permission(DataPermissions.entityRead), async (c) => {
|
|
||||||
const $id = `${this.config.basepath}/schema.json`;
|
|
||||||
const schemas = Object.fromEntries(
|
|
||||||
this.em.entities.map((e) => [
|
|
||||||
e.name,
|
|
||||||
{
|
|
||||||
$ref: `${this.config.basepath}/schemas/${e.name}`
|
|
||||||
}
|
|
||||||
])
|
|
||||||
);
|
|
||||||
return c.json({
|
|
||||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
$id,
|
|
||||||
properties: schemas
|
|
||||||
});
|
|
||||||
})
|
|
||||||
// read schema
|
|
||||||
.get(
|
|
||||||
"/schemas/:entity/:context?",
|
|
||||||
permission(DataPermissions.entityRead),
|
|
||||||
tb(
|
|
||||||
"param",
|
|
||||||
Type.Object({
|
|
||||||
entity: Type.String(),
|
|
||||||
context: Type.Optional(StringEnum(["create", "update"]))
|
|
||||||
})
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
//console.log("request", c.req.raw);
|
|
||||||
const { entity, context } = c.req.param();
|
|
||||||
if (!this.entityExists(entity)) {
|
|
||||||
console.log("not found", entity, definedEntities);
|
|
||||||
return c.notFound();
|
|
||||||
}
|
|
||||||
const _entity = this.em.entity(entity);
|
|
||||||
const schema = _entity.toSchema({ context } as any);
|
|
||||||
const url = new URL(c.req.url);
|
|
||||||
const base = `${url.origin}${this.config.basepath}`;
|
|
||||||
const $id = `${this.config.basepath}/schemas/${entity}`;
|
|
||||||
return c.json({
|
|
||||||
$schema: `${base}/schema.json`,
|
|
||||||
$id,
|
|
||||||
title: _entity.label,
|
|
||||||
$comment: _entity.config.description,
|
|
||||||
...schema
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
|
||||||
// read many
|
// read many
|
||||||
.get(
|
.get(
|
||||||
"/:entity",
|
"/:entity",
|
||||||
@@ -208,7 +256,7 @@ export class DataController extends Controller {
|
|||||||
//console.log("request", c.req.raw);
|
//console.log("request", c.req.raw);
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
console.log("not found", entity, definedEntities);
|
console.warn("not found:", entity, definedEntities);
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
}
|
}
|
||||||
const options = c.req.valid("query") as RepoQuery;
|
const options = c.req.valid("query") as RepoQuery;
|
||||||
@@ -327,12 +375,9 @@ export class DataController extends Controller {
|
|||||||
// delete one
|
// delete one
|
||||||
.delete(
|
.delete(
|
||||||
"/:entity/:id",
|
"/:entity/:id",
|
||||||
|
|
||||||
permission(DataPermissions.entityDelete),
|
permission(DataPermissions.entityDelete),
|
||||||
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
tb("param", Type.Object({ entity: Type.String(), id: tbNumber })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
this.guard.throwUnlessGranted(DataPermissions.entityDelete);
|
|
||||||
|
|
||||||
const { entity, id } = c.req.param();
|
const { entity, id } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
@@ -350,14 +395,11 @@ export class DataController extends Controller {
|
|||||||
tb("param", Type.Object({ entity: Type.String() })),
|
tb("param", Type.Object({ entity: Type.String() })),
|
||||||
tb("json", querySchema.properties.where),
|
tb("json", querySchema.properties.where),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
//console.log("request", c.req.raw);
|
|
||||||
const { entity } = c.req.param();
|
const { entity } = c.req.param();
|
||||||
if (!this.entityExists(entity)) {
|
if (!this.entityExists(entity)) {
|
||||||
return c.notFound();
|
return c.notFound();
|
||||||
}
|
}
|
||||||
const where = c.req.valid("json") as RepoQuery["where"];
|
const where = c.req.valid("json") as RepoQuery["where"];
|
||||||
//console.log("where", where);
|
|
||||||
|
|
||||||
const result = await this.em.mutator(entity).deleteWhere(where);
|
const result = await this.em.mutator(entity).deleteWhere(where);
|
||||||
|
|
||||||
return c.json(this.mutatorResult(result));
|
return c.json(this.mutatorResult(result));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
|
import { type Client, type Config, type InStatement, createClient } from "@libsql/client";
|
||||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||||
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin, sql } from "kysely";
|
import { type DatabaseIntrospector, Kysely, ParseJSONResultsPlugin } from "kysely";
|
||||||
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
|
import { FilterNumericKeysPlugin } from "../plugins/FilterNumericKeysPlugin";
|
||||||
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
|
import { KyselyPluginRunner } from "../plugins/KyselyPluginRunner";
|
||||||
import type { QB } from "./Connection";
|
import type { QB } from "./Connection";
|
||||||
@@ -28,7 +28,7 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
constructor(clientOrCredentials: Client | LibSqlCredentials) {
|
||||||
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
|
const plugins = [new FilterNumericKeysPlugin(), new ParseJSONResultsPlugin()];
|
||||||
let client: Client;
|
let client: Client;
|
||||||
if ("url" in clientOrCredentials) {
|
if (clientOrCredentials && "url" in clientOrCredentials) {
|
||||||
let { url, authToken, protocol } = clientOrCredentials;
|
let { url, authToken, protocol } = clientOrCredentials;
|
||||||
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
if (protocol && LIBSQL_PROTOCOLS.includes(protocol)) {
|
||||||
console.log("changing protocol to", protocol);
|
console.log("changing protocol to", protocol);
|
||||||
@@ -36,11 +36,8 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
url = `${protocol}://${rest}`;
|
url = `${protocol}://${rest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("using", url, { protocol });
|
|
||||||
|
|
||||||
client = createClient({ url, authToken });
|
client = createClient({ url, authToken });
|
||||||
} else {
|
} else {
|
||||||
//console.log("-- client provided");
|
|
||||||
client = clientOrCredentials;
|
client = clientOrCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +45,6 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
// @ts-expect-error libsql has type issues
|
// @ts-expect-error libsql has type issues
|
||||||
dialect: new CustomLibsqlDialect({ client }),
|
dialect: new CustomLibsqlDialect({ client }),
|
||||||
plugins
|
plugins
|
||||||
//log: ["query"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
super(kysely, {}, plugins);
|
super(kysely, {}, plugins);
|
||||||
@@ -74,7 +70,6 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
}> {
|
}> {
|
||||||
const stms: InStatement[] = queries.map((q) => {
|
const stms: InStatement[] = queries.map((q) => {
|
||||||
const compiled = q.compile();
|
const compiled = q.compile();
|
||||||
//console.log("compiled", compiled.sql, compiled.parameters);
|
|
||||||
return {
|
return {
|
||||||
sql: compiled.sql,
|
sql: compiled.sql,
|
||||||
args: compiled.parameters as any[]
|
args: compiled.parameters as any[]
|
||||||
@@ -91,7 +86,6 @@ export class LibsqlConnection extends SqliteConnection {
|
|||||||
const rows = await kyselyPlugins.transformResultRows(r.rows);
|
const rows = await kyselyPlugins.transformResultRows(r.rows);
|
||||||
data.push(rows);
|
data.push(rows);
|
||||||
}
|
}
|
||||||
//console.log("data", data);
|
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
ExpressionBuilder,
|
ExpressionBuilder,
|
||||||
Kysely,
|
Kysely,
|
||||||
SchemaMetadata,
|
SchemaMetadata,
|
||||||
TableMetadata,
|
TableMetadata
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
|
import { DEFAULT_MIGRATION_LOCK_TABLE, DEFAULT_MIGRATION_TABLE, sql } from "kysely";
|
||||||
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
|
import type { ConnectionIntrospector, IndexMetadata } from "./Connection";
|
||||||
@@ -62,7 +62,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
seqno: number;
|
seqno: number;
|
||||||
cid: number;
|
cid: number;
|
||||||
name: string;
|
name: string;
|
||||||
}>`pragma_index_info(${index})`.as("index_info"),
|
}>`pragma_index_info(${index})`.as("index_info")
|
||||||
)
|
)
|
||||||
.select(["seqno", "cid", "name"])
|
.select(["seqno", "cid", "name"])
|
||||||
.orderBy("cid")
|
.orderBy("cid")
|
||||||
@@ -74,8 +74,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
isUnique: isUnique,
|
isUnique: isUnique,
|
||||||
columns: columns.map((col) => ({
|
columns: columns.map((col) => ({
|
||||||
name: col.name,
|
name: col.name,
|
||||||
order: col.seqno,
|
order: col.seqno
|
||||||
})),
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTables(
|
async getTables(
|
||||||
options: DatabaseMetadataOptions = { withInternalKyselyTables: false },
|
options: DatabaseMetadataOptions = { withInternalKyselyTables: false }
|
||||||
): Promise<TableMetadata[]> {
|
): Promise<TableMetadata[]> {
|
||||||
let query = this.#db
|
let query = this.#db
|
||||||
.selectFrom("sqlite_master")
|
.selectFrom("sqlite_master")
|
||||||
@@ -99,7 +99,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
|
|
||||||
if (!options.withInternalKyselyTables) {
|
if (!options.withInternalKyselyTables) {
|
||||||
query = query.where(
|
query = query.where(
|
||||||
this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE]),
|
this.excludeTables([DEFAULT_MIGRATION_TABLE, DEFAULT_MIGRATION_LOCK_TABLE])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this._excludeTables.length > 0) {
|
if (this._excludeTables.length > 0) {
|
||||||
@@ -112,7 +112,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
|
|
||||||
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
|
async getMetadata(options?: DatabaseMetadataOptions): Promise<DatabaseMetadata> {
|
||||||
return {
|
return {
|
||||||
tables: await this.getTables(options),
|
tables: await this.getTables(options)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
type: string;
|
type: string;
|
||||||
notnull: 0 | 1;
|
notnull: 0 | 1;
|
||||||
dflt_value: any;
|
dflt_value: any;
|
||||||
}>`pragma_table_info(${table})`.as("table_info"),
|
}>`pragma_table_info(${table})`.as("table_info")
|
||||||
)
|
)
|
||||||
.select(["name", "type", "notnull", "dflt_value"])
|
.select(["name", "type", "notnull", "dflt_value"])
|
||||||
.orderBy("cid")
|
.orderBy("cid")
|
||||||
@@ -157,8 +157,8 @@ export class SqliteIntrospector implements DatabaseIntrospector, ConnectionIntro
|
|||||||
isNullable: !col.notnull,
|
isNullable: !col.notnull,
|
||||||
isAutoIncrementing: col.name === autoIncrementCol,
|
isAutoIncrementing: col.name === autoIncrementCol,
|
||||||
hasDefaultValue: col.dflt_value != null,
|
hasDefaultValue: col.dflt_value != null,
|
||||||
comment: undefined,
|
comment: undefined
|
||||||
})),
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,6 +165,13 @@ export class Entity<
|
|||||||
return this.getField(name);
|
return this.getField(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasField(name: string): boolean;
|
||||||
|
hasField(field: Field): boolean;
|
||||||
|
hasField(nameOrField: string | Field): boolean {
|
||||||
|
const name = typeof nameOrField === "string" ? nameOrField : nameOrField.name;
|
||||||
|
return this.fields.findIndex((field) => field.name === name) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
getFields(include_virtual: boolean = false): Field[] {
|
getFields(include_virtual: boolean = false): Field[] {
|
||||||
if (include_virtual) return this.fields;
|
if (include_virtual) return this.fields;
|
||||||
return this.fields.filter((f) => !f.isVirtual());
|
return this.fields.filter((f) => !f.isVirtual());
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export class Mutator<
|
|||||||
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
protected async many(qb: MutatorQB): Promise<MutatorResponse> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
const { sql, parameters } = qb.compile();
|
const { sql, parameters } = qb.compile();
|
||||||
//console.log("mutatoar:exec", sql, parameters);
|
|
||||||
|
try {
|
||||||
const result = await qb.execute();
|
const result = await qb.execute();
|
||||||
|
|
||||||
const data = this.em.hydrate(entity.name, result) as EntityData[];
|
const data = this.em.hydrate(entity.name, result) as EntityData[];
|
||||||
@@ -119,6 +120,11 @@ export class Mutator<
|
|||||||
result: result,
|
result: result,
|
||||||
data
|
data
|
||||||
};
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// @todo: redact
|
||||||
|
console.log("[Error in query]", sql);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> {
|
protected async single(qb: MutatorQB): Promise<MutatorResponse<EntityData>> {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
import type { DB as DefaultDB, PrimaryFieldType } from "core";
|
||||||
|
import { $console } from "core";
|
||||||
import { type EmitsEvents, EventManager } from "core/events";
|
import { type EmitsEvents, EventManager } from "core/events";
|
||||||
import { type SelectQueryBuilder, sql } from "kysely";
|
import { type SelectQueryBuilder, sql } from "kysely";
|
||||||
import { cloneDeep } from "lodash-es";
|
import { cloneDeep } from "lodash-es";
|
||||||
@@ -73,7 +74,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
sort: entity.getDefaultSort(),
|
sort: entity.getDefaultSort(),
|
||||||
select: entity.getSelect()
|
select: entity.getSelect()
|
||||||
};
|
};
|
||||||
//console.log("validated", validated);
|
|
||||||
|
|
||||||
if (!options) return validated;
|
if (!options) return validated;
|
||||||
|
|
||||||
@@ -144,7 +144,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
throw new InvalidSearchParamsException(`Invalid where field(s): ${invalid.join(", ")}`);
|
throw new InvalidSearchParamsException(
|
||||||
|
`Invalid where field(s): ${invalid.join(", ")}`
|
||||||
|
).context({ aliases, entity: entity.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
validated.where = options.where;
|
validated.where = options.where;
|
||||||
@@ -160,7 +162,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
protected async performQuery(qb: RepositoryQB): Promise<RepositoryResponse> {
|
||||||
const entity = this.entity;
|
const entity = this.entity;
|
||||||
const compiled = qb.compile();
|
const compiled = qb.compile();
|
||||||
//console.log("performQuery", compiled.sql, compiled.parameters);
|
//$console.log("performQuery", compiled.sql, compiled.parameters);
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
|
const selector = (as = "count") => this.conn.fn.countAll<number>().as(as);
|
||||||
@@ -179,7 +181,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
totalQuery,
|
totalQuery,
|
||||||
qb
|
qb
|
||||||
]);
|
]);
|
||||||
//console.log("result", { _count, _total });
|
//$console.log("result", { _count, _total });
|
||||||
|
|
||||||
const time = Number.parseFloat((performance.now() - start).toFixed(2));
|
const time = Number.parseFloat((performance.now() - start).toFixed(2));
|
||||||
const data = this.em.hydrate(entity.name, result);
|
const data = this.em.hydrate(entity.name, result);
|
||||||
@@ -200,7 +202,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
console.error("[ERROR] Repository.performQuery", e.message);
|
$console.error("[ERROR] Repository.performQuery", e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
@@ -253,7 +255,7 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
...config?.defaults
|
...config?.defaults
|
||||||
};
|
};
|
||||||
|
|
||||||
/*console.log("build query options", {
|
/*$console.log("build query options", {
|
||||||
entity: entity.name,
|
entity: entity.name,
|
||||||
options,
|
options,
|
||||||
config
|
config
|
||||||
@@ -334,7 +336,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
|
|
||||||
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
|
async findMany(_options?: Partial<RepoQuery>): Promise<RepositoryResponse<TBD[TB][]>> {
|
||||||
const { qb, options } = this.buildQuery(_options);
|
const { qb, options } = this.buildQuery(_options);
|
||||||
//console.log("findMany:options", options);
|
|
||||||
|
|
||||||
await this.emgr.emit(
|
await this.emgr.emit(
|
||||||
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
|
new Repository.Events.RepositoryFindManyBefore({ entity: this.entity, options })
|
||||||
@@ -386,7 +387,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
//console.log("findManyOptions", newEntity.name, findManyOptions);
|
|
||||||
return this.cloneFor(newEntity).findMany(findManyOptions);
|
return this.cloneFor(newEntity).findMany(findManyOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,9 +427,9 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
qb = qb.limit(1);
|
qb = qb.limit(1);
|
||||||
|
|
||||||
const compiled = qb.compile();
|
const compiled = qb.compile();
|
||||||
//console.log("exists query", compiled.sql, compiled.parameters);
|
//$console.log("exists query", compiled.sql, compiled.parameters);
|
||||||
const result = await qb.execute();
|
const result = await qb.execute();
|
||||||
//console.log("result", result);
|
//$console.log("result", result);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sql: compiled.sql,
|
sql: compiled.sql,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class PrimaryField<Required extends true | false = false> extends Field<
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async transformPersist(value: any): Promise<number> {
|
override async transformPersist(value: any): Promise<number> {
|
||||||
throw new Error("This function should not be called");
|
throw new Error("PrimaryField: This function should not be called");
|
||||||
}
|
}
|
||||||
|
|
||||||
override toJsonSchema() {
|
override toJsonSchema() {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export { Connection } from "./connection/Connection";
|
|||||||
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
|
export { LibsqlConnection, type LibSqlCredentials } from "./connection/LibsqlConnection";
|
||||||
export { SqliteConnection } from "./connection/SqliteConnection";
|
export { SqliteConnection } from "./connection/SqliteConnection";
|
||||||
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
|
export { SqliteLocalConnection } from "./connection/SqliteLocalConnection";
|
||||||
|
export { SqliteIntrospector } from "./connection/SqliteIntrospector";
|
||||||
|
export { KyselyPluginRunner } from "./plugins/KyselyPluginRunner";
|
||||||
|
|
||||||
export { constructEntity, constructRelation } from "./schema/constructor";
|
export { constructEntity, constructRelation } from "./schema/constructor";
|
||||||
|
|
||||||
@@ -28,3 +30,5 @@ export const DatabaseEvents = {
|
|||||||
export { MutatorEvents, RepositoryEvents };
|
export { MutatorEvents, RepositoryEvents };
|
||||||
|
|
||||||
export * as DataPermissions from "./permissions";
|
export * as DataPermissions from "./permissions";
|
||||||
|
|
||||||
|
export { MediaField, type MediaFieldConfig, type MediaItem } from "media/MediaField";
|
||||||
|
|||||||
@@ -105,13 +105,13 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
|||||||
}
|
}
|
||||||
|
|
||||||
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
override getReferenceQuery(entity: Entity, id: number): Partial<RepoQuery> {
|
||||||
const conn = this.connectionEntity;
|
const { other, otherRef } = this.getQueryInfo(entity);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
where: {
|
where: {
|
||||||
[`${conn.name}.${entity.name}_${entity.getPrimaryField().name}`]: id
|
[otherRef]: id
|
||||||
},
|
},
|
||||||
join: [this.target.reference]
|
join: [other.reference]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,48 +160,28 @@ export class ManyToManyRelation extends EntityRelation<typeof ManyToManyRelation
|
|||||||
.whereRef(entityRef, "=", otherRef)
|
.whereRef(entityRef, "=", otherRef)
|
||||||
.innerJoin(...join)
|
.innerJoin(...join)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
/*return qb.select((eb) => {
|
|
||||||
const select: any[] = other.entity.getSelect(other.entity.name);
|
|
||||||
// @todo: also add to find by references
|
|
||||||
if (additionalFields.length > 0) {
|
|
||||||
const conn = this.connectionEntity.name;
|
|
||||||
select.push(
|
|
||||||
jsonBuildObject(
|
|
||||||
Object.fromEntries(
|
|
||||||
additionalFields.map((f) => [f.name, eb.ref(`${conn}.${f.name}`)])
|
|
||||||
)
|
|
||||||
).as(this.connectionTableMappedName)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jsonFrom(
|
|
||||||
eb
|
|
||||||
.selectFrom(other.entity.name)
|
|
||||||
.select(select)
|
|
||||||
.whereRef(entityRef, "=", otherRef)
|
|
||||||
.innerJoin(...join)
|
|
||||||
.limit(limit)
|
|
||||||
).as(other.reference);
|
|
||||||
});*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(em: EntityManager<any>) {
|
initialize(em: EntityManager<any>) {
|
||||||
this.em = em;
|
this.em = em;
|
||||||
|
|
||||||
//this.connectionEntity.addField(new RelationField(this.source.entity));
|
const sourceField = RelationField.create(this, this.source);
|
||||||
//this.connectionEntity.addField(new RelationField(this.target.entity));
|
const targetField = RelationField.create(this, this.target);
|
||||||
this.connectionEntity.addField(RelationField.create(this, this.source));
|
|
||||||
this.connectionEntity.addField(RelationField.create(this, this.target));
|
|
||||||
|
|
||||||
// @todo: check this
|
if (em.hasEntity(this.connectionEntity)) {
|
||||||
for (const field of this.additionalFields) {
|
// @todo: also check for correct signatures of field
|
||||||
this.source.entity.addField(new VirtualField(this.connectionTableMappedName));
|
if (!this.connectionEntity.hasField(sourceField)) {
|
||||||
this.target.entity.addField(new VirtualField(this.connectionTableMappedName));
|
this.connectionEntity.addField(sourceField);
|
||||||
}
|
}
|
||||||
|
if (!this.connectionEntity.hasField(targetField)) {
|
||||||
|
this.connectionEntity.addField(targetField);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.connectionEntity.addField(sourceField);
|
||||||
|
this.connectionEntity.addField(targetField);
|
||||||
em.addEntity(this.connectionEntity);
|
em.addEntity(this.connectionEntity);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override getName(): string {
|
override getName(): string {
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
|
override async transformPersist(value: any, em: EntityManager<any>): Promise<any> {
|
||||||
throw new Error("This function should not be called");
|
throw new Error("RelationField: This function should not be called");
|
||||||
}
|
}
|
||||||
|
|
||||||
override toJsonSchema() {
|
override toJsonSchema() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { PrimaryFieldType } from "core";
|
|||||||
import type { Entity, EntityManager } from "../entities";
|
import type { Entity, EntityManager } from "../entities";
|
||||||
import {
|
import {
|
||||||
type EntityRelation,
|
type EntityRelation,
|
||||||
|
ManyToManyRelation,
|
||||||
type MutationOperation,
|
type MutationOperation,
|
||||||
MutationOperations,
|
MutationOperations,
|
||||||
RelationField
|
RelationField
|
||||||
@@ -26,11 +27,26 @@ export class RelationMutator {
|
|||||||
*/
|
*/
|
||||||
getRelationalKeys(): string[] {
|
getRelationalKeys(): string[] {
|
||||||
const references: string[] = [];
|
const references: string[] = [];
|
||||||
|
|
||||||
|
// if persisting a manytomany connection table
|
||||||
|
// @todo: improve later
|
||||||
|
if (this.entity.type === "generated") {
|
||||||
|
const relation = this.em.relations.all.find(
|
||||||
|
(r) => r instanceof ManyToManyRelation && r.connectionEntity.name === this.entity.name
|
||||||
|
);
|
||||||
|
if (relation instanceof ManyToManyRelation) {
|
||||||
|
references.push(
|
||||||
|
...this.entity.fields.filter((f) => f.type === "relation").map((f) => f.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.em.relationsOf(this.entity.name).map((r) => {
|
this.em.relationsOf(this.entity.name).map((r) => {
|
||||||
const info = r.helper(this.entity.name).getMutationInfo();
|
const info = r.helper(this.entity.name).getMutationInfo();
|
||||||
references.push(info.reference);
|
references.push(info.reference);
|
||||||
info.local_field && references.push(info.local_field);
|
info.local_field && references.push(info.local_field);
|
||||||
});
|
});
|
||||||
|
|
||||||
return references;
|
return references;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
type StaticDecode,
|
type StaticDecode,
|
||||||
StringEnum,
|
StringEnum,
|
||||||
Type,
|
Type,
|
||||||
Value
|
Value,
|
||||||
|
isObject
|
||||||
} from "core/utils";
|
} from "core/utils";
|
||||||
import { WhereBuilder, type WhereQuery } from "../entities";
|
import { WhereBuilder, type WhereQuery } from "../entities";
|
||||||
|
|
||||||
@@ -71,22 +72,51 @@ export type RepoWithSchema = Record<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
|
export const withSchema = <TSelf extends TThis>(Self: TSelf) =>
|
||||||
Type.Transform(Type.Union([stringArray, Type.Record(Type.String(), Self)]))
|
Type.Transform(
|
||||||
|
Type.Union([Type.String(), Type.Array(Type.String()), Type.Record(Type.String(), Self)])
|
||||||
|
)
|
||||||
.Decode((value) => {
|
.Decode((value) => {
|
||||||
let _value = typeof value === "string" ? [value] : value;
|
// images
|
||||||
|
// images,comments
|
||||||
|
// ["images","comments"]
|
||||||
|
// { "images": {} }
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (!Array.isArray(value) && isObject(value)) {
|
||||||
if (!value.every((v) => typeof v === "string")) {
|
console.log("is object");
|
||||||
|
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");
|
throw new Error("Invalid 'with' schema");
|
||||||
}
|
}
|
||||||
|
|
||||||
_value = value.reduce((acc, v) => {
|
return _value.reduce((acc, v) => {
|
||||||
acc[v] = {};
|
acc[v] = {};
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as RepoWithSchema);
|
}, {} as RepoWithSchema);
|
||||||
}
|
|
||||||
|
|
||||||
return _value as RepoWithSchema;
|
|
||||||
})
|
})
|
||||||
.Encode((value) => value);
|
.Encode((value) => value);
|
||||||
|
|
||||||
@@ -117,7 +147,7 @@ export type RepoQueryIn = {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
sort?: string | { by: string; dir: "asc" | "desc" };
|
sort?: string | { by: string; dir: "asc" | "desc" };
|
||||||
select?: string[];
|
select?: string[];
|
||||||
with?: string[] | Record<string, RepoQueryIn>;
|
with?: string | string[] | Record<string, RepoQueryIn>;
|
||||||
join?: string[];
|
join?: string[];
|
||||||
where?: WhereQuery;
|
where?: WhereQuery;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export class AppFlows extends Module<typeof flowsConfigSchema> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
hono.all("*", (c) => c.notFound());
|
||||||
|
|
||||||
this.ctx.server.route(this.config.basepath, hono);
|
this.ctx.server.route(this.config.basepath, hono);
|
||||||
|
|
||||||
// register flows
|
// register flows
|
||||||
|
|||||||
+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 {
|
export {
|
||||||
getDefaultConfig,
|
getDefaultConfig,
|
||||||
@@ -6,11 +13,12 @@ export {
|
|||||||
type ModuleConfigs,
|
type ModuleConfigs,
|
||||||
type ModuleSchemas,
|
type ModuleSchemas,
|
||||||
type ModuleManagerOptions,
|
type ModuleManagerOptions,
|
||||||
type ModuleBuildContext
|
type ModuleBuildContext,
|
||||||
|
type InitialModuleConfigs
|
||||||
} from "./modules/ModuleManager";
|
} from "./modules/ModuleManager";
|
||||||
|
|
||||||
export * as middlewares from "modules/middlewares";
|
export * as middlewares from "modules/middlewares";
|
||||||
export { registries } from "modules/registries";
|
export { registries } from "modules/registries";
|
||||||
|
|
||||||
export type * from "./adapter";
|
export type { MediaFieldSchema } from "media/AppMedia";
|
||||||
export { Api, type ApiOptions } from "./Api";
|
export type { UserFieldSchema } from "auth/AppAuth";
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
let adapter: StorageAdapter;
|
let adapter: StorageAdapter;
|
||||||
try {
|
try {
|
||||||
const { type, config } = this.config.adapter;
|
const { type, config } = this.config.adapter;
|
||||||
adapter = new (registry.get(type as any).cls)(config as any);
|
const cls = registry.get(type as any).cls;
|
||||||
|
adapter = new cls(config as any);
|
||||||
|
|
||||||
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
|
this._storage = new Storage(adapter, this.config.storage, this.ctx.emgr);
|
||||||
this.setBuilt();
|
this.setBuilt();
|
||||||
@@ -53,8 +54,6 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
index(media).on(["path"], true).on(["reference"]);
|
index(media).on(["path"], true).on(["reference"]);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setBuilt();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -124,11 +123,11 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
async (e) => {
|
async (e) => {
|
||||||
const mutator = em.mutator(media);
|
const mutator = em.mutator(media);
|
||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
await mutator.insertOne(this.uploadedEventDataToMediaPayload(e.params));
|
const payload = this.uploadedEventDataToMediaPayload(e.params);
|
||||||
|
await mutator.insertOne(payload);
|
||||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
console.log("App:storage:file uploaded", e);
|
|
||||||
},
|
},
|
||||||
"sync"
|
{ mode: "sync", id: "add-data-media" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// when file is deleted, sync with media entity
|
// when file is deleted, sync with media entity
|
||||||
@@ -144,7 +143,7 @@ export class AppMedia extends Module<typeof mediaConfigSchema> {
|
|||||||
|
|
||||||
console.log("App:storage:file deleted", e);
|
console.log("App:storage:file deleted", e);
|
||||||
},
|
},
|
||||||
"sync"
|
{ mode: "sync", id: "delete-data-media" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { type BaseModuleApiOptions, ModuleApi, type PrimaryFieldType } from "modules/ModuleApi";
|
import type { FileListObject } from "media";
|
||||||
|
import {
|
||||||
|
type BaseModuleApiOptions,
|
||||||
|
ModuleApi,
|
||||||
|
type PrimaryFieldType,
|
||||||
|
type TInput
|
||||||
|
} from "modules/ModuleApi";
|
||||||
import type { FileWithPath } from "ui/elements/media/file-selector";
|
import type { FileWithPath } from "ui/elements/media/file-selector";
|
||||||
|
|
||||||
export type MediaApiOptions = BaseModuleApiOptions & {};
|
export type MediaApiOptions = BaseModuleApiOptions & {};
|
||||||
@@ -10,12 +16,32 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getFiles() {
|
listFiles() {
|
||||||
return this.get(["files"]);
|
return this.get<FileListObject[]>(["files"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
getFile(filename: string) {
|
getFile(filename: string) {
|
||||||
return this.get(["file", filename]);
|
return this.get<ReadableStream<Uint8Array>>(["file", filename], undefined, {
|
||||||
|
headers: {
|
||||||
|
Accept: "*/*"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
getFileUploadUrl(file: FileWithPath): string {
|
||||||
@@ -32,10 +58,93 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadFile(file: File) {
|
protected uploadFile(
|
||||||
const formData = new FormData();
|
body: File | ReadableStream,
|
||||||
formData.append("file", file);
|
opts?: {
|
||||||
return this.post(["upload"], formData);
|
filename?: string;
|
||||||
|
path?: TInput;
|
||||||
|
_init?: Omit<RequestInit, "body">;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
...(opts?._init?.headers || {})
|
||||||
|
};
|
||||||
|
let name: string = opts?.filename || "";
|
||||||
|
try {
|
||||||
|
if (typeof (body as File).type !== "undefined") {
|
||||||
|
headers["Content-Type"] = (body as File).type;
|
||||||
|
}
|
||||||
|
if (!opts?.filename) {
|
||||||
|
name = (body as File).name;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (name && name.length > 0 && name.includes("/")) {
|
||||||
|
name = name.split("/").pop() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = {
|
||||||
|
...(opts?._init || {}),
|
||||||
|
headers
|
||||||
|
};
|
||||||
|
if (opts?.path) {
|
||||||
|
return this.post(opts.path, body, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || name.length === 0) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.post(opts?.path ?? ["upload", name], body, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upload(
|
||||||
|
item: Request | Response | string | File | ReadableStream,
|
||||||
|
opts: {
|
||||||
|
filename?: string;
|
||||||
|
_init?: Omit<RequestInit, "body">;
|
||||||
|
path?: TInput;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
if (item instanceof Request || typeof item === "string") {
|
||||||
|
const res = await this.fetcher(item);
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
throw new Error("Failed to fetch file");
|
||||||
|
}
|
||||||
|
return this.uploadFile(res.body, opts);
|
||||||
|
} else if (item instanceof Response) {
|
||||||
|
if (!item.body) {
|
||||||
|
throw new Error("Invalid response");
|
||||||
|
}
|
||||||
|
return this.uploadFile(item.body, {
|
||||||
|
...(opts ?? {}),
|
||||||
|
_init: {
|
||||||
|
...(opts._init ?? {}),
|
||||||
|
headers: {
|
||||||
|
...(opts._init?.headers ?? {}),
|
||||||
|
"Content-Type": item.headers.get("Content-Type") || "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.uploadFile(item, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadToEntity(
|
||||||
|
entity: string,
|
||||||
|
id: PrimaryFieldType,
|
||||||
|
field: string,
|
||||||
|
item: Request | Response | string | File | ReadableStream,
|
||||||
|
opts?: {
|
||||||
|
_init?: Omit<RequestInit, "body">;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return this.upload(item, {
|
||||||
|
...opts,
|
||||||
|
path: ["entity", entity, id, field]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFile(filename: string) {
|
deleteFile(filename: string) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { tbValidator as tb } from "core";
|
import { isDebug, tbValidator as tb } from "core";
|
||||||
import { Type } from "core/utils";
|
import { HttpStatus, Type, getFileFromContext } from "core/utils";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
|
||||||
import type { StorageAdapter } from "media";
|
import type { StorageAdapter } from "media";
|
||||||
import { StorageEvents, getRandomizedFilename } from "media";
|
import { StorageEvents, getRandomizedFilename } from "media";
|
||||||
import { Controller } from "modules/Controller";
|
import { Controller } from "modules/Controller";
|
||||||
@@ -42,7 +41,6 @@ export class MediaController extends Controller {
|
|||||||
if (!filename) {
|
if (!filename) {
|
||||||
throw new Error("No file name provided");
|
throw new Error("No file name provided");
|
||||||
}
|
}
|
||||||
//console.log("getting file", filename, headersToObject(c.req.raw.headers));
|
|
||||||
|
|
||||||
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
|
await this.getStorage().emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
|
||||||
return await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
return await this.getStorageAdapter().getObject(filename, c.req.raw.headers);
|
||||||
@@ -59,24 +57,40 @@ export class MediaController extends Controller {
|
|||||||
return c.json({ message: "File deleted" });
|
return c.json({ message: "File deleted" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadSizeMiddleware = bodyLimit({
|
const maxSize = this.getStorage().getConfig().body_max_size ?? Number.POSITIVE_INFINITY;
|
||||||
maxSize: this.getStorage().getConfig().body_max_size,
|
|
||||||
onError: (c: any) => {
|
if (isDebug()) {
|
||||||
return c.text(`Payload exceeds ${this.getStorage().getConfig().body_max_size}`, 413);
|
hono.post("/inspect", async (c) => {
|
||||||
}
|
const file = await getFileFromContext(c);
|
||||||
|
return c.json({
|
||||||
|
type: file?.type,
|
||||||
|
name: file?.name,
|
||||||
|
size: file?.size
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// upload file
|
// upload file
|
||||||
// @todo: add required type for "upload endpoints"
|
// @todo: add required type for "upload endpoints"
|
||||||
hono.post("/upload/:filename", uploadSizeMiddleware, async (c) => {
|
hono.post("/upload/:filename", async (c) => {
|
||||||
const { filename } = c.req.param();
|
const { filename } = c.req.param();
|
||||||
if (!filename) {
|
if (!filename) {
|
||||||
throw new Error("No file name provided");
|
throw new Error("No file name provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await this.getStorage().getFileFromRequest(c);
|
const body = await getFileFromContext(c);
|
||||||
console.log("----file", file);
|
if (!body) {
|
||||||
return c.json(await this.getStorage().uploadFile(file, filename));
|
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 res = await this.getStorage().uploadFile(body, filename);
|
||||||
|
return c.json(res, HttpStatus.CREATED);
|
||||||
});
|
});
|
||||||
|
|
||||||
// add upload file to entity
|
// add upload file to entity
|
||||||
@@ -89,23 +103,21 @@ export class MediaController extends Controller {
|
|||||||
overwrite: Type.Optional(booleanLike)
|
overwrite: Type.Optional(booleanLike)
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
uploadSizeMiddleware,
|
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const entity_name = c.req.param("entity");
|
const entity_name = c.req.param("entity");
|
||||||
const field_name = c.req.param("field");
|
const field_name = c.req.param("field");
|
||||||
const entity_id = Number.parseInt(c.req.param("id"));
|
const entity_id = Number.parseInt(c.req.param("id"));
|
||||||
console.log("params", { entity_name, field_name, entity_id });
|
|
||||||
|
|
||||||
// check if entity exists
|
// check if entity exists
|
||||||
const entity = this.media.em.entity(entity_name);
|
const entity = this.media.em.entity(entity_name);
|
||||||
if (!entity) {
|
if (!entity) {
|
||||||
return c.json({ error: `Entity "${entity_name}" not found` }, 404);
|
return c.json({ error: `Entity "${entity_name}" not found` }, HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if field exists and is of type MediaField
|
// check if field exists and is of type MediaField
|
||||||
const field = entity.field(field_name);
|
const field = entity.field(field_name);
|
||||||
if (!field || !(field instanceof MediaField)) {
|
if (!field || !(field instanceof MediaField)) {
|
||||||
return c.json({ error: `Invalid field "${field_name}"` }, 400);
|
return c.json({ error: `Invalid field "${field_name}"` }, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const media_entity = this.media.getMediaEntity().name as "media";
|
const media_entity = this.media.getMediaEntity().name as "media";
|
||||||
@@ -127,7 +139,10 @@ export class MediaController extends Controller {
|
|||||||
if (count >= max_items) {
|
if (count >= max_items) {
|
||||||
// if overwrite not set, abort early
|
// if overwrite not set, abort early
|
||||||
if (!overwrite) {
|
if (!overwrite) {
|
||||||
return c.json({ error: `Max items (${max_items}) reached` }, 400);
|
return c.json(
|
||||||
|
{ error: `Max items (${max_items}) reached` },
|
||||||
|
HttpStatus.BAD_REQUEST
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if already more in database than allowed, abort early
|
// if already more in database than allowed, abort early
|
||||||
@@ -135,7 +150,7 @@ export class MediaController extends Controller {
|
|||||||
if (count > max_items) {
|
if (count > max_items) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: `Max items (${max_items}) exceeded already with ${count} items.` },
|
{ error: `Max items (${max_items}) exceeded already with ${count} items.` },
|
||||||
400
|
HttpStatus.UNPROCESSABLE_ENTITY
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,11 +176,21 @@ export class MediaController extends Controller {
|
|||||||
if (!exists) {
|
if (!exists) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` },
|
{ error: `Entity "${entity_name}" with ID "${entity_id}" doesn't exist found` },
|
||||||
404
|
HttpStatus.NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await 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 file = await this.getStorage().getFileFromRequest(c);
|
|
||||||
const file_name = getRandomizedFilename(file as File);
|
const file_name = getRandomizedFilename(file as File);
|
||||||
const info = await this.getStorage().uploadFile(file, file_name, true);
|
const info = await this.getStorage().uploadFile(file, file_name, true);
|
||||||
|
|
||||||
@@ -185,10 +210,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
|
config: adapter.schema
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: name,
|
title: adapter.schema?.title ?? name,
|
||||||
|
description: adapter.schema?.description,
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { type EmitsEvents, EventManager } from "core/events";
|
import { type EmitsEvents, EventManager } from "core/events";
|
||||||
import type { TSchema } from "core/utils";
|
import { type TSchema, isFile } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { isMimeType } from "media/storage/mime-types-tiny";
|
||||||
import { bodyLimit } from "hono/body-limit";
|
|
||||||
import * as StorageEvents from "./events";
|
import * as StorageEvents from "./events";
|
||||||
import type { FileUploadedEventData } from "./events";
|
import type { FileUploadedEventData } from "./events";
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ export type FileListObject = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FileMeta = { type: string; size: number };
|
export type FileMeta = { type: string; size: number };
|
||||||
export type FileBody = ReadableStream | ArrayBuffer | ArrayBufferView | string | null | Blob | File;
|
export type FileBody = ReadableStream | File;
|
||||||
export type FileUploadPayload = {
|
export type FileUploadPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
meta: FileMeta;
|
meta: FileMeta;
|
||||||
@@ -38,7 +37,7 @@ export interface StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StorageConfig = {
|
export type StorageConfig = {
|
||||||
body_max_size: number;
|
body_max_size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Storage implements EmitsEvents {
|
export class Storage implements EmitsEvents {
|
||||||
@@ -55,7 +54,7 @@ export class Storage implements EmitsEvents {
|
|||||||
this.#adapter = adapter;
|
this.#adapter = adapter;
|
||||||
this.config = {
|
this.config = {
|
||||||
...config,
|
...config,
|
||||||
body_max_size: config.body_max_size ?? 20 * 1024 * 1024
|
body_max_size: config.body_max_size
|
||||||
};
|
};
|
||||||
|
|
||||||
this.emgr = emgr ?? new EventManager();
|
this.emgr = emgr ?? new EventManager();
|
||||||
@@ -82,26 +81,33 @@ export class Storage implements EmitsEvents {
|
|||||||
noEmit?: boolean
|
noEmit?: boolean
|
||||||
): Promise<FileUploadedEventData> {
|
): Promise<FileUploadedEventData> {
|
||||||
const result = await this.#adapter.putObject(name, file);
|
const result = await this.#adapter.putObject(name, file);
|
||||||
console.log("result", result);
|
if (typeof result === "undefined") {
|
||||||
|
|
||||||
let info: FileUploadPayload;
|
|
||||||
|
|
||||||
switch (typeof result) {
|
|
||||||
case "undefined":
|
|
||||||
throw new Error("Failed to upload file");
|
throw new Error("Failed to upload file");
|
||||||
case "string": {
|
}
|
||||||
// get object meta
|
|
||||||
|
let info: FileUploadPayload = {
|
||||||
|
name,
|
||||||
|
meta: {
|
||||||
|
size: 0,
|
||||||
|
type: "application/octet-stream"
|
||||||
|
},
|
||||||
|
etag: typeof result === "string" ? result : ""
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof result === "object") {
|
||||||
|
info = result;
|
||||||
|
} else if (isFile(file)) {
|
||||||
|
info.meta.size = file.size;
|
||||||
|
info.meta.type = file.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to get better meta info
|
||||||
|
if (!isMimeType(info?.meta.type, ["application/octet-stream", "application/json"])) {
|
||||||
const meta = await this.#adapter.getObjectMeta(name);
|
const meta = await this.#adapter.getObjectMeta(name);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
throw new Error("Failed to get object meta");
|
throw new Error("Failed to get object meta");
|
||||||
}
|
}
|
||||||
|
info.meta = meta;
|
||||||
info = { name, meta, etag: result };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "object":
|
|
||||||
info = result;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
@@ -127,102 +133,4 @@ export class Storage implements EmitsEvents {
|
|||||||
async fileExists(name: string) {
|
async fileExists(name: string) {
|
||||||
return await this.#adapter.objectExists(name);
|
return await this.#adapter.objectExists(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
getController(): any {
|
|
||||||
// @todo: multiple providers?
|
|
||||||
// @todo: implement range requests
|
|
||||||
|
|
||||||
const hono = new Hono();
|
|
||||||
|
|
||||||
// get files list (temporary)
|
|
||||||
hono.get("/files", async (c) => {
|
|
||||||
const files = await this.#adapter.listObjects();
|
|
||||||
return c.json(files);
|
|
||||||
});
|
|
||||||
|
|
||||||
// get file by name
|
|
||||||
hono.get("/file/:filename", async (c) => {
|
|
||||||
const { filename } = c.req.param();
|
|
||||||
if (!filename) {
|
|
||||||
throw new Error("No file name provided");
|
|
||||||
}
|
|
||||||
//console.log("getting file", filename, headersToObject(c.req.raw.headers));
|
|
||||||
|
|
||||||
await this.emgr.emit(new StorageEvents.FileAccessEvent({ name: filename }));
|
|
||||||
return await this.#adapter.getObject(filename, c.req.raw.headers);
|
|
||||||
});
|
|
||||||
|
|
||||||
// delete a file by name
|
|
||||||
hono.delete("/file/:filename", async (c) => {
|
|
||||||
const { filename } = c.req.param();
|
|
||||||
if (!filename) {
|
|
||||||
throw new Error("No file name provided");
|
|
||||||
}
|
|
||||||
await this.deleteFile(filename);
|
|
||||||
|
|
||||||
return c.json({ message: "File deleted" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// upload file
|
|
||||||
hono.post(
|
|
||||||
"/upload/:filename",
|
|
||||||
bodyLimit({
|
|
||||||
maxSize: this.config.body_max_size,
|
|
||||||
onError: (c: any) => {
|
|
||||||
return c.text(`Payload exceeds ${this.config.body_max_size}`, 413);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
async (c) => {
|
|
||||||
const { filename } = c.req.param();
|
|
||||||
if (!filename) {
|
|
||||||
throw new Error("No file name provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = await this.getFileFromRequest(c);
|
|
||||||
return c.json(await this.uploadFile(file, filename));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return hono;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If uploaded through HttpPie -> ReadableStream
|
|
||||||
* If uploaded in tests -> file == ReadableStream
|
|
||||||
* If uploaded in FE -> content_type:body multipart/form-data; boundary=----WebKitFormBoundary7euoBFF12B0AHWLn
|
|
||||||
* file File {
|
|
||||||
* size: 223052,
|
|
||||||
* type: 'image/png',
|
|
||||||
* name: 'noise_white.png',
|
|
||||||
* lastModified: 1731743671176
|
|
||||||
* }
|
|
||||||
* @param c
|
|
||||||
*/
|
|
||||||
async getFileFromRequest(c: Context): Promise<FileBody> {
|
|
||||||
const content_type = c.req.header("Content-Type") ?? "application/octet-stream";
|
|
||||||
console.log("content_type:body", content_type);
|
|
||||||
const body = c.req.raw.body;
|
|
||||||
if (!body) {
|
|
||||||
throw new Error("No body");
|
|
||||||
}
|
|
||||||
|
|
||||||
let file: FileBody | undefined;
|
|
||||||
if (content_type?.startsWith("multipart/form-data")) {
|
|
||||||
file = (await c.req.formData()).get("file") as File;
|
|
||||||
// @todo: check nextjs, it's not *that* [File] type (but it's uploadable)
|
|
||||||
if (typeof file === "undefined") {
|
|
||||||
throw new Error("No file given at form data 'file'");
|
|
||||||
}
|
|
||||||
/*console.log("file", file);
|
|
||||||
if (!(file instanceof File)) {
|
|
||||||
throw new Error("No file given at form data 'file'");
|
|
||||||
}*/
|
|
||||||
} else if (content_type?.startsWith("application/octet-stream")) {
|
|
||||||
file = body;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unsupported content type: ${content_type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,10 @@ export const cloudinaryAdapterConfig = Type.Object(
|
|||||||
api_secret: Type.String(),
|
api_secret: Type.String(),
|
||||||
upload_preset: Type.Optional(Type.String())
|
upload_preset: Type.Optional(Type.String())
|
||||||
},
|
},
|
||||||
{ title: "Cloudinary" }
|
{ title: "Cloudinary", description: "Cloudinary media storage" }
|
||||||
);
|
);
|
||||||
|
|
||||||
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
export type CloudinaryConfig = Static<typeof cloudinaryAdapterConfig>;
|
||||||
/*export type CloudinaryConfig = {
|
|
||||||
cloud_name: string;
|
|
||||||
api_key: string;
|
|
||||||
api_secret: string;
|
|
||||||
upload_preset?: string;
|
|
||||||
};*/
|
|
||||||
|
|
||||||
type CloudinaryObject = {
|
type CloudinaryObject = {
|
||||||
asset_id: string;
|
asset_id: string;
|
||||||
@@ -91,10 +85,8 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async putObject(_key: string, body: FileBody) {
|
async putObject(_key: string, body: FileBody) {
|
||||||
//console.log("_key", _key);
|
|
||||||
// remove extension, as it is added by cloudinary
|
// remove extension, as it is added by cloudinary
|
||||||
const key = _key.replace(/\.[a-z0-9]{2,5}$/, "");
|
const key = _key.replace(/\.[a-z0-9]{2,5}$/, "");
|
||||||
//console.log("key", key);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", body as any);
|
formData.append("file", body as any);
|
||||||
@@ -117,21 +109,12 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
|||||||
body: formData
|
body: formData
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
//console.log("putObject:cloudinary", formData);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
/*console.log(
|
|
||||||
"failed to upload using cloudinary",
|
|
||||||
Object.fromEntries(formData.entries()),
|
|
||||||
result
|
|
||||||
);*/
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("putObject:result", result);
|
|
||||||
|
|
||||||
const data = (await result.json()) as CloudinaryPutObjectResponse;
|
const data = (await result.json()) as CloudinaryPutObjectResponse;
|
||||||
//console.log("putObject:result:json", data);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: data.public_id + "." + data.format,
|
name: data.public_id + "." + data.format,
|
||||||
@@ -154,7 +137,6 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
//console.log("result", result);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error("Failed to list objects");
|
throw new Error("Failed to list objects");
|
||||||
@@ -179,10 +161,7 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async objectExists(key: string): Promise<boolean> {
|
async objectExists(key: string): Promise<boolean> {
|
||||||
//console.log("--object exists?", key);
|
|
||||||
const result = await this.headObject(key);
|
const result = await this.headObject(key);
|
||||||
//console.log("object exists", result);
|
|
||||||
|
|
||||||
return result.ok;
|
return result.ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +193,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
|||||||
const type = this.guessType(key) ?? "image";
|
const type = this.guessType(key) ?? "image";
|
||||||
|
|
||||||
const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`;
|
const objectUrl = `https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/${key}`;
|
||||||
//console.log("objectUrl", objectUrl);
|
|
||||||
return objectUrl;
|
return objectUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
async getObject(key: string, headers: Headers): Promise<Response> {
|
||||||
//console.log("url", this.getObjectUrl(key));
|
|
||||||
const res = await fetch(this.getObjectUrl(key), {
|
const res = await fetch(this.getObjectUrl(key), {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: pickHeaders(headers, ["range"])
|
headers: pickHeaders(headers, ["range"])
|
||||||
@@ -237,14 +214,10 @@ export class StorageCloudinaryAdapter implements StorageAdapter {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("public_ids[]", key);
|
formData.append("public_ids[]", key);
|
||||||
|
|
||||||
const result = await fetch(
|
await fetch(`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`, {
|
||||||
`https://res.cloudinary.com/${this.config.cloud_name}/${type}/upload/`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: formData
|
body: formData
|
||||||
}
|
});
|
||||||
);
|
|
||||||
//console.log("deleteObject:result", result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
import { type Static, Type, parse } from "core/utils";
|
import { type Static, Type, isFile, parse } from "core/utils";
|
||||||
import type { FileBody, FileListObject, FileMeta, StorageAdapter } from "../../Storage";
|
import type {
|
||||||
|
FileBody,
|
||||||
|
FileListObject,
|
||||||
|
FileMeta,
|
||||||
|
FileUploadPayload,
|
||||||
|
StorageAdapter
|
||||||
|
} from "../../Storage";
|
||||||
import { guess } from "../../mime-types-tiny";
|
import { guess } from "../../mime-types-tiny";
|
||||||
|
|
||||||
export const localAdapterConfig = Type.Object(
|
export const localAdapterConfig = Type.Object(
|
||||||
{
|
{
|
||||||
path: Type.String({ default: "./" })
|
path: Type.String({ default: "./" })
|
||||||
},
|
},
|
||||||
{ title: "Local" }
|
{ title: "Local", description: "Local file system storage" }
|
||||||
);
|
);
|
||||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||||
|
|
||||||
@@ -43,8 +49,9 @@ export class StorageLocalAdapter implements StorageAdapter {
|
|||||||
return fileStats;
|
return fileStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async computeEtag(content: BufferSource): Promise<string> {
|
private async computeEtag(body: FileBody): Promise<string> {
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", content);
|
const content = isFile(body) ? body : new Response(body);
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", await content.arrayBuffer());
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||||
|
|
||||||
@@ -52,17 +59,16 @@ export class StorageLocalAdapter implements StorageAdapter {
|
|||||||
return `"${hashHex}"`;
|
return `"${hashHex}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async putObject(key: string, body: FileBody): Promise<string> {
|
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
|
||||||
if (body === null) {
|
if (body === null) {
|
||||||
throw new Error("Body is empty");
|
throw new Error("Body is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: this is too hacky
|
|
||||||
const file = body as File;
|
|
||||||
|
|
||||||
const filePath = `${this.config.path}/${key}`;
|
const filePath = `${this.config.path}/${key}`;
|
||||||
await writeFile(filePath, file.stream());
|
const is_file = isFile(body);
|
||||||
return await this.computeEtag(await file.arrayBuffer());
|
await writeFile(filePath, is_file ? body.stream() : body);
|
||||||
|
|
||||||
|
return await this.computeEtag(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteObject(key: string): Promise<void> {
|
async deleteObject(key: string): Promise<void> {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
PutObjectRequest
|
PutObjectRequest
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
import { AwsClient, isDebug } from "core";
|
import { AwsClient, isDebug } from "core";
|
||||||
import { type Static, Type, parse, pickHeaders } from "core/utils";
|
import { type Static, Type, isFile, parse, pickHeaders } from "core/utils";
|
||||||
import { transform } from "lodash-es";
|
import { transform } from "lodash-es";
|
||||||
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
|
import type { FileBody, FileListObject, StorageAdapter } from "../Storage";
|
||||||
|
|
||||||
@@ -25,7 +25,8 @@ export const s3AdapterConfig = Type.Object(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "S3"
|
title: "AWS S3",
|
||||||
|
description: "AWS S3 or compatible storage"
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -81,17 +82,14 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const url = this.getUrl("", params);
|
const url = this.getUrl("", params);
|
||||||
//console.log("url", url);
|
|
||||||
const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
|
const res = await this.fetchJson<{ ListBucketResult: ListObjectsV2Output }>(url, {
|
||||||
method: "GET"
|
method: "GET"
|
||||||
});
|
});
|
||||||
//console.log("res", res);
|
|
||||||
|
|
||||||
// absolutely weird, but if only one object is there, it's an object, not an array
|
// absolutely weird, but if only one object is there, it's an object, not an array
|
||||||
const { Contents } = res.ListBucketResult;
|
const { Contents } = res.ListBucketResult;
|
||||||
const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents];
|
const objects = !Contents ? [] : Array.isArray(Contents) ? Contents : [Contents];
|
||||||
|
|
||||||
//console.log(JSON.stringify(res.ListBucketResult, null, 2), objects);
|
|
||||||
const transformed = transform(
|
const transformed = transform(
|
||||||
objects,
|
objects,
|
||||||
(acc, obj) => {
|
(acc, obj) => {
|
||||||
@@ -106,28 +104,21 @@ export class StorageS3Adapter extends AwsClient implements StorageAdapter {
|
|||||||
},
|
},
|
||||||
[] as FileListObject[]
|
[] as FileListObject[]
|
||||||
);
|
);
|
||||||
//console.log(transformed);
|
|
||||||
|
|
||||||
return transformed;
|
return transformed;
|
||||||
}
|
}
|
||||||
|
|
||||||
async putObject(
|
async putObject(
|
||||||
key: string,
|
key: string,
|
||||||
body: FileBody | null,
|
body: FileBody,
|
||||||
// @todo: params must be added as headers, skipping for now
|
// @todo: params must be added as headers, skipping for now
|
||||||
params: Omit<PutObjectRequest, "Bucket" | "Key"> = {}
|
params: Omit<PutObjectRequest, "Bucket" | "Key"> = {}
|
||||||
) {
|
) {
|
||||||
const url = this.getUrl(key, {});
|
const url = this.getUrl(key, {});
|
||||||
//console.log("url", url);
|
|
||||||
const res = await this.fetch(url, {
|
const res = await this.fetch(url, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body
|
body
|
||||||
});
|
});
|
||||||
/*console.log("putObject:raw:res", {
|
|
||||||
ok: res.ok,
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
});*/
|
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// "df20fcb574dba1446cf5ec997940492b"
|
// "df20fcb574dba1446cf5ec997940492b"
|
||||||
|
|||||||
@@ -75,3 +75,21 @@ export function guess(f: string): string {
|
|||||||
return c.a();
|
return c.a();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMimeType(mime: any, exclude: string[] = []) {
|
||||||
|
for (const [k, v] of M.entries()) {
|
||||||
|
if (v === mime && !exclude.includes(k)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extension(mime: string) {
|
||||||
|
for (const [k, v] of M.entries()) {
|
||||||
|
if (v === mime) {
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ export function getRandomizedFilename(file: File | string, length = 16): string
|
|||||||
throw new Error("Invalid file name");
|
throw new Error("Invalid file name");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @todo: use uuid instead?
|
||||||
return [randomString(length), getExtension(filename)].filter(Boolean).join(".");
|
return [randomString(length), getExtension(filename)].filter(Boolean).join(".");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { App } from "App";
|
import type { App } from "App";
|
||||||
import type { Guard } from "auth";
|
import type { Guard } from "auth";
|
||||||
import { SchemaObject } from "core";
|
import { type DebugLogger, SchemaObject } from "core";
|
||||||
import type { EventManager } from "core/events";
|
import type { EventManager } from "core/events";
|
||||||
import type { Static, TSchema } from "core/utils";
|
import type { Static, TSchema } from "core/utils";
|
||||||
import {
|
import {
|
||||||
@@ -18,13 +18,14 @@ import { isEqual } from "lodash-es";
|
|||||||
|
|
||||||
export type ServerEnv = {
|
export type ServerEnv = {
|
||||||
Variables: {
|
Variables: {
|
||||||
app?: App;
|
app: App;
|
||||||
// to prevent resolving auth multiple times
|
// to prevent resolving auth multiple times
|
||||||
auth_resolved?: boolean;
|
auth?: {
|
||||||
// to only register once
|
resolved: boolean;
|
||||||
auth_registered?: boolean;
|
registered: boolean;
|
||||||
// whether or not to bypass auth
|
skip: boolean;
|
||||||
auth_skip?: boolean;
|
user?: { id: any; role?: string; [key: string]: any };
|
||||||
|
};
|
||||||
html?: string;
|
html?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -35,6 +36,7 @@ export type ModuleBuildContext = {
|
|||||||
em: EntityManager;
|
em: EntityManager;
|
||||||
emgr: EventManager<any>;
|
emgr: EventManager<any>;
|
||||||
guard: Guard;
|
guard: Guard;
|
||||||
|
logger: DebugLogger;
|
||||||
flags: (typeof Module)["ctx_flags"];
|
flags: (typeof Module)["ctx_flags"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user