mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc282b795e | |||
| bc65f5f311 | |||
| 5355f2593e | |||
| e939debba5 | |||
| b35ee36fb1 | |||
| 109c72e84f | |||
| 7e0b02a125 | |||
| d4076803c6 | |||
| a5959acb34 | |||
| a4ddf780ce | |||
| 2f684765de | |||
| 22b54862e8 | |||
| 45138c25f0 | |||
| 80034b9b0a | |||
| e6ee75c712 | |||
| 56287eb05e | |||
| 144f35ec66 | |||
| d1378c6c51 | |||
| 4e10b36d0d | |||
| 70737c04cd | |||
| d41fd5541f | |||
| e76e3531d7 | |||
| a9f3a582eb | |||
| 57ae2f333c | |||
| b2086c4da7 | |||
| 344d729320 | |||
| c86f4c12b7 | |||
| aaa97ed113 | |||
| 69c8aec6fb | |||
| f5ceffd80e | |||
| a9f367aa2c | |||
| b87696a0db | |||
| 3338804c34 | |||
| 6b3ac9e6e2 | |||
| 8e1ecfcfe3 | |||
| 6e2596fb77 | |||
| 2ada4e9f20 | |||
| cc038a0a9a | |||
| af6d1960b9 | |||
| 96ae8f9d63 | |||
| c09d1d53b3 | |||
| 2239333f08 | |||
| 0710b5bf1f | |||
| 03c4c41d2d | |||
| 85d8542a2e | |||
| f8d2a9090e | |||
| bbb7bfb7a1 | |||
| 28e277afe1 | |||
| 8517c9b90b | |||
| fe5ccd4206 | |||
| 8b4b63b3cd | |||
| 9c4aac8843 | |||
| d5bb6ffa61 | |||
| fc513bb413 | |||
| 4162b9878a | |||
| c75f8d0937 | |||
| 046c1d21b1 | |||
| 6c2e579596 | |||
| 88419548c7 | |||
| 12c955155d | |||
| 15af9f5261 | |||
| 39efb911e9 | |||
| 4729203d47 | |||
| 0db052acca | |||
| 6e08f45857 | |||
| 7bff84d601 | |||
| e66e05b2b0 | |||
| 5b318ce485 | |||
| 22de16fe17 | |||
| 5d26673bc6 | |||
| dfa4bc0e33 | |||
| e51b89a18a | |||
| 63988e0c5f | |||
| 297fd85a4f | |||
| a82fbe7400 | |||
| 74e1e9a03f | |||
| 05a81b5bdc | |||
| 9ff49103fb | |||
| a5b59c004e | |||
| a0edcf483b | |||
| 9a18e354cd | |||
| 58c7aba1a4 | |||
| f8aa242d2b | |||
| 3da9570abe | |||
| 6ee898e606 | |||
| abbd372ddf | |||
| 3e77982996 | |||
| 3fbea8ace7 | |||
| a41f943b43 | |||
| 15a9c549e7 | |||
| 50cadbaa8e | |||
| 7b128c9701 | |||
| 061181d59d | |||
| af6cb0c8f0 | |||
| 5a693c0370 | |||
| 262588decc | |||
| 17ab35e245 | |||
| db795ec050 | |||
| 773df544dd | |||
| 0ac7d1fd6e | |||
| 6694c63990 | |||
| b3f95f9552 | |||
| 372f94d22a | |||
| d6f94a2ce1 | |||
| 89a39a7dc6 | |||
| 88cf65f792 |
@@ -12,10 +12,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22.x"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: "1.2.5"
|
bun-version: "1.2.14"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
@@ -30,3 +30,5 @@ packages/media/.env
|
|||||||
.vscode
|
.vscode
|
||||||
.git_old
|
.git_old
|
||||||
docker/tmp
|
docker/tmp
|
||||||
|
.debug
|
||||||
|
.history
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
[](https://npmjs.org/package/bknd)
|
[](https://npmjs.org/package/bknd)
|
||||||
[](https://www.npmjs.com/package/bknd)
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -10,22 +9,30 @@
|
|||||||
</p>
|
</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!
|
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!
|
||||||
|
* **Runtimes**: Node.js 22+, Bun 1.0+, Deno, Browser, Cloudflare Workers/Pages, Vercel, Netlify, AWS Lambda, etc.
|
||||||
|
* **Databases**:
|
||||||
|
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
||||||
|
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
||||||
|
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
||||||
|
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
|
||||||
|
|
||||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
|
> This project requires Node.js 22 or higher (because of `node:sqlite`).
|
||||||
|
>
|
||||||
> 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.
|
||||||
|
|
||||||
## Size
|
## Size
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
|
The size on npm is misleading, as the `bknd` package includes the backend, the ui components as well as the whole backend bundled into the cli including static assets.
|
||||||
|
|
||||||
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 212 kB gzipped (e.g. deployed as Cloudflare Worker).
|
Depending on what you use, the size can be higher as additional dependencies are getting pulled in. The minimal size of a full `bknd` app as an API is around 300 kB gzipped (e.g. deployed as Cloudflare Worker).
|
||||||
|
|
||||||
## Motivation
|
## Motivation
|
||||||
Creating digital products always requires developing both the backend (the logic) and the frontend (the appearance). Building a backend from scratch demands deep knowledge in areas such as authentication and database management. Using a backend framework can speed up initial development, but it still requires ongoing effort to work within its constraints (e.g., *"how to do X with Y?"*), which can quickly slow you down. Choosing a backend system is a tough decision, as you might not be aware of its limitations until you encounter them.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# ===== DB Settings =====
|
||||||
|
VITE_DB_URL=:memory:
|
||||||
|
# you can set a location for a database here, it'll overwrite the previous setting
|
||||||
|
# ideally use the ".db" folder (create it first), it's git ignored
|
||||||
|
VITE_DB_URL=file:.db/dev.db
|
||||||
|
|
||||||
|
# alternatively, you can use url/token combination
|
||||||
|
#VITE_DB_URL=
|
||||||
|
#VITE_DB_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
|
# ===== DEV Server =====
|
||||||
|
# restart the dev server on every change (enable with "1")
|
||||||
|
VITE_APP_FRESH=
|
||||||
|
# displays react-scan widget (enable with "1")
|
||||||
|
VITE_DEBUG_RERENDERS=
|
||||||
|
# console logs registered routes on start (enable with "1")
|
||||||
|
VITE_SHOW_ROUTES=
|
||||||
|
|
||||||
|
|
||||||
|
# ===== Test Credentials =====
|
||||||
|
RESEND_API_KEY=
|
||||||
|
R2_TOKEN=
|
||||||
|
|
||||||
|
R2_ACCESS_KEY=
|
||||||
|
R2_SECRET_ACCESS_KEY=
|
||||||
|
R2_URL=
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY=
|
||||||
|
AWS_SECRET_KEY=
|
||||||
|
AWS_S3_URL=
|
||||||
|
|
||||||
|
OAUTH_CLIENT_ID=
|
||||||
|
OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
PUBLIC_POSTHOG_KEY=
|
||||||
|
PUBLIC_POSTHOG_HOST=
|
||||||
|
|
||||||
|
# ===== Internals =====
|
||||||
|
BKND_CLI_CREATE_REF=main
|
||||||
|
BKND_CLI_LOG_LEVEL=debug
|
||||||
|
BKND_MODULES_DEBUG=1
|
||||||
+128
-10
@@ -1,6 +1,9 @@
|
|||||||
import { afterAll, afterEach, describe, expect, test } from "bun:test";
|
import { afterEach, describe, test, expect } from "bun:test";
|
||||||
import { App } from "../src";
|
import { App, createApp } from "core/test/utils";
|
||||||
import { getDummyConnection } from "./helper";
|
import { getDummyConnection } from "./helper";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import * as proto from "../src/data/prototype";
|
||||||
|
import { pick } from "lodash-es";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
afterEach(afterAllCleanup);
|
afterEach(afterAllCleanup);
|
||||||
@@ -10,18 +13,133 @@ describe("App tests", async () => {
|
|||||||
const app = new App(dummyConnection);
|
const app = new App(dummyConnection);
|
||||||
await app.build();
|
await app.build();
|
||||||
|
|
||||||
//expect(await app.data?.em.ping()).toBeTrue();
|
expect(await app.em.ping()).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
/*test.only("what", async () => {
|
test("plugins", async () => {
|
||||||
const app = new App(dummyConnection, {
|
const called: string[] = [];
|
||||||
|
const app = createApp({
|
||||||
|
initialConfig: {
|
||||||
auth: {
|
auth: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: [
|
||||||
|
(app) => {
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
expect(app).toBeInstanceOf(App);
|
||||||
|
return {
|
||||||
|
name: "test",
|
||||||
|
schema: () => {
|
||||||
|
called.push("schema");
|
||||||
|
return proto.em(
|
||||||
|
{
|
||||||
|
posts: proto.entity("posts", {
|
||||||
|
title: proto.text(),
|
||||||
|
}),
|
||||||
|
comments: proto.entity("comments", {
|
||||||
|
content: proto.text(),
|
||||||
|
}),
|
||||||
|
users: proto.entity("users", {
|
||||||
|
email_verified: proto.boolean(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
(fn, s) => {
|
||||||
|
fn.relation(s.comments).manyToOne(s.posts);
|
||||||
|
fn.index(s.posts).on(["title"]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onBoot: async () => {
|
||||||
|
called.push("onBoot");
|
||||||
|
},
|
||||||
|
beforeBuild: async () => {
|
||||||
|
called.push("beforeBuild");
|
||||||
|
},
|
||||||
|
onBuilt: async () => {
|
||||||
|
called.push("onBuilt");
|
||||||
|
},
|
||||||
|
onServerInit: async (server) => {
|
||||||
|
called.push("onServerInit");
|
||||||
|
expect(server).toBeDefined();
|
||||||
|
expect(server).toBeInstanceOf(Hono);
|
||||||
|
},
|
||||||
|
onFirstBoot: async () => {
|
||||||
|
called.push("onFirstBoot");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
expect(app.em.entities.map((e) => e.name)).toEqual(["users", "posts", "comments"]);
|
||||||
|
expect(app.em.indices.map((i) => i.name)).toEqual([
|
||||||
|
"idx_unique_users_email",
|
||||||
|
"idx_users_strategy",
|
||||||
|
"idx_users_strategy_value",
|
||||||
|
"idx_posts_title",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
app.em.relations.all.map((r) => pick(r.toJSON(), ["type", "source", "target"])),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
type: "n:1",
|
||||||
|
source: "comments",
|
||||||
|
target: "posts",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(called).toEqual([
|
||||||
|
"onBoot",
|
||||||
|
"onServerInit",
|
||||||
|
"beforeBuild",
|
||||||
|
"onServerInit",
|
||||||
|
"schema",
|
||||||
|
"onFirstBoot",
|
||||||
|
"onBuilt",
|
||||||
|
]);
|
||||||
|
expect(app.plugins.size).toBe(1);
|
||||||
|
expect(Array.from(app.plugins.keys())).toEqual(["test"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.only("drivers", async () => {
|
||||||
|
const called: string[] = [];
|
||||||
|
const app = new App(dummyConnection, undefined, {
|
||||||
|
drivers: {
|
||||||
|
email: {
|
||||||
|
send: async (to, subject, body) => {
|
||||||
|
called.push("email.send");
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cache: {
|
||||||
|
get: async (key) => {
|
||||||
|
called.push("cache.get");
|
||||||
|
return "";
|
||||||
|
},
|
||||||
|
set: async (key, value, ttl) => {
|
||||||
|
called.push("cache.set");
|
||||||
|
},
|
||||||
|
del: async (key) => {
|
||||||
|
called.push("cache.del");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await app.build();
|
||||||
|
|
||||||
|
expect(app.drivers.cache).toBeDefined();
|
||||||
|
expect(app.drivers.email).toBeDefined();
|
||||||
|
await app.drivers.email.send("", "", "");
|
||||||
|
await app.drivers.cache.get("");
|
||||||
|
await app.drivers.cache.set("", "", 0);
|
||||||
|
await app.drivers.cache.del("");
|
||||||
|
|
||||||
|
expect(called).toEqual(["email.send", "cache.get", "cache.set", "cache.del"]);
|
||||||
});
|
});
|
||||||
await app.module.auth.build();
|
|
||||||
await app.module.data.build();
|
|
||||||
console.log(app.em.entities.map((e) => e.name));
|
|
||||||
console.log(await app.em.schema().getDiff());
|
|
||||||
});*/
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,20 +3,35 @@ import * as adapter from "adapter";
|
|||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import { omitKeys } from "core/utils";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("adapter", () => {
|
describe("adapter", () => {
|
||||||
it("makes config", () => {
|
it("makes config", () => {
|
||||||
expect(adapter.makeConfig({})).toEqual({});
|
expect(omitKeys(adapter.makeConfig({}), ["connection"])).toEqual({});
|
||||||
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
|
expect(omitKeys(adapter.makeConfig({}, { env: { TEST: "test" } }), ["connection"])).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
// merges everything returned from `app` with the config
|
// merges everything returned from `app` with the config
|
||||||
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({
|
expect(
|
||||||
env: { TEST: "test" },
|
omitKeys(
|
||||||
} as any);
|
adapter.makeConfig(
|
||||||
|
{ app: (a) => ({ initialConfig: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
|
{ env: { TEST: "test" } },
|
||||||
|
),
|
||||||
|
["connection"],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
initialConfig: { server: { cors: { origin: "test" } } },
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* it.only("...", async () => {
|
||||||
|
const app = await adapter.createAdapterApp();
|
||||||
|
}); */
|
||||||
|
|
||||||
it("reuses apps correctly", async () => {
|
it("reuses apps correctly", async () => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ describe("DataApi", () => {
|
|||||||
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
|
const oneBy = api.readOneBy("posts", { where: { title: "baz" }, select: ["title"] });
|
||||||
const oneByRes = await oneBy;
|
const oneByRes = await oneBy;
|
||||||
expect(oneByRes.data).toEqual({ title: "baz" } as any);
|
expect(oneByRes.data).toEqual({ title: "baz" } as any);
|
||||||
expect(oneByRes.body.meta.count).toEqual(1);
|
expect(oneByRes.body.meta.items).toEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exists/count", async () => {
|
it("exists/count", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// <reference types="@types/bun" />
|
/// <reference types="@types/bun" />
|
||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { getFileFromContext, isFile, isReadableStream } from "../../src/core/utils";
|
import { getFileFromContext, isFile, isReadableStream } from "core/utils";
|
||||||
import { MediaApi } from "../../src/media/api/MediaApi";
|
import { MediaApi } from "media/api/MediaApi";
|
||||||
import { assetsPath, assetsTmpPath } from "../helper";
|
import { assetsPath, assetsTmpPath } from "../helper";
|
||||||
|
|
||||||
const mockedBackend = new Hono()
|
const mockedBackend = new Hono()
|
||||||
@@ -39,10 +39,28 @@ describe("MediaApi", () => {
|
|||||||
// @ts-ignore tests
|
// @ts-ignore tests
|
||||||
const api = new MediaApi({
|
const api = new MediaApi({
|
||||||
token: "token",
|
token: "token",
|
||||||
|
token_transport: "header",
|
||||||
});
|
});
|
||||||
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
|
expect(api.getUploadHeaders().get("Authorization")).toBe("Bearer token");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should return empty headers if not using `header` transport", () => {
|
||||||
|
expect(
|
||||||
|
new MediaApi({
|
||||||
|
token_transport: "cookie",
|
||||||
|
})
|
||||||
|
.getUploadHeaders()
|
||||||
|
.has("Authorization"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
new MediaApi({
|
||||||
|
token_transport: "none",
|
||||||
|
})
|
||||||
|
.getUploadHeaders()
|
||||||
|
.has("Authorization"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("should get file: native", async () => {
|
it("should get file: native", async () => {
|
||||||
const name = "image.png";
|
const name = "image.png";
|
||||||
const path = `${assetsTmpPath}/${name}`;
|
const path = `${assetsTmpPath}/${name}`;
|
||||||
@@ -103,8 +121,12 @@ describe("MediaApi", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should upload file in various ways", async () => {
|
it("should upload file in various ways", async () => {
|
||||||
// @ts-ignore tests
|
const api = new MediaApi(
|
||||||
const api = new MediaApi({}, mockedBackend.request);
|
{
|
||||||
|
upload_fetcher: mockedBackend.request,
|
||||||
|
},
|
||||||
|
mockedBackend.request,
|
||||||
|
);
|
||||||
const file = Bun.file(`${assetsPath}/image.png`);
|
const file = Bun.file(`${assetsPath}/image.png`);
|
||||||
|
|
||||||
async function matches(req: Promise<any>, filename: string) {
|
async function matches(req: Promise<any>, filename: string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, mock, test } from "bun:test";
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
import type { ModuleBuildContext } from "../../src";
|
import type { ModuleBuildContext } from "../../src";
|
||||||
import { App, createApp } from "../../src/App";
|
import { App, createApp } from "core/test/utils";
|
||||||
import * as proto from "../../src/data/prototype";
|
import * as proto from "../../src/data/prototype";
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
@@ -20,6 +20,7 @@ describe("App", () => {
|
|||||||
"guard",
|
"guard",
|
||||||
"flags",
|
"flags",
|
||||||
"logger",
|
"logger",
|
||||||
|
"helper",
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createApp, registries } from "../../src";
|
import { registries } from "../../src";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
import * as proto from "../../src/data/prototype";
|
import * as proto from "../../src/data/prototype";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { TObject, TString } from "@sinclair/typebox";
|
import { type TObject, type TString, Type } from "@sinclair/typebox";
|
||||||
import { Registry } from "../../src/core/registry/Registry";
|
import { Registry } from "core";
|
||||||
import { type TSchema, Type } from "../../src/core/utils";
|
|
||||||
|
|
||||||
type Constructor<T> = new (...args: any[]) => T;
|
type Constructor<T> = new (...args: any[]) => T;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { SchemaObject } from "../../../src/core";
|
import { SchemaObject } from "../../../src/core";
|
||||||
import { Type } from "../../../src/core/utils";
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
describe("SchemaObject", async () => {
|
describe("SchemaObject", async () => {
|
||||||
test("basic", async () => {
|
test("basic", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { type ObjectQuery, convert, validate } from "../../../src/core/object/query/object-query";
|
import { type ObjectQuery, convert, validate } from "core/object/query/object-query";
|
||||||
|
|
||||||
describe("object-query", () => {
|
describe("object-query", () => {
|
||||||
const q: ObjectQuery = { name: "Michael" };
|
const q: ObjectQuery = { name: "Michael" };
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
type EntityData,
|
type EntityData,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
ManyToOneRelation,
|
ManyToOneRelation,
|
||||||
type MutatorResponse,
|
|
||||||
type RepositoryResponse,
|
|
||||||
TextField,
|
TextField,
|
||||||
} from "../../src/data";
|
} from "../../src/data";
|
||||||
import { DataController } from "../../src/data/api/DataController";
|
import { DataController } from "../../src/data/api/DataController";
|
||||||
import { dataConfigSchema } from "../../src/data/data-schema";
|
import { dataConfigSchema } from "../../src/data/data-schema";
|
||||||
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
import { disableConsoleLog, enableConsoleLog, getDummyConnection } from "../helper";
|
||||||
|
import type { RepositoryResultJSON } from "data/entities/query/RepositoryResult";
|
||||||
|
import type { MutatorResultJSON } from "data/entities/mutation/MutatorResult";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
||||||
@@ -21,52 +21,6 @@ afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
|||||||
|
|
||||||
const dataConfig = parse(dataConfigSchema, {});
|
const dataConfig = parse(dataConfigSchema, {});
|
||||||
describe("[data] DataController", async () => {
|
describe("[data] DataController", async () => {
|
||||||
test("repoResult", async () => {
|
|
||||||
const em = new EntityManager<any>([], dummyConnection);
|
|
||||||
const ctx: any = { em, guard: new Guard() };
|
|
||||||
const controller = new DataController(ctx, dataConfig);
|
|
||||||
|
|
||||||
const res = controller.repoResult({
|
|
||||||
entity: null as any,
|
|
||||||
data: [] as any,
|
|
||||||
sql: "",
|
|
||||||
parameters: [] as any,
|
|
||||||
result: [] as any,
|
|
||||||
meta: {
|
|
||||||
total: 0,
|
|
||||||
count: 0,
|
|
||||||
items: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res).toEqual({
|
|
||||||
meta: {
|
|
||||||
total: 0,
|
|
||||||
count: 0,
|
|
||||||
items: 0,
|
|
||||||
},
|
|
||||||
data: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mutatorResult", async () => {
|
|
||||||
const em = new EntityManager([], dummyConnection);
|
|
||||||
const ctx: any = { em, guard: new Guard() };
|
|
||||||
const controller = new DataController(ctx, dataConfig);
|
|
||||||
|
|
||||||
const res = controller.mutatorResult({
|
|
||||||
entity: null as any,
|
|
||||||
data: [] as any,
|
|
||||||
sql: "",
|
|
||||||
parameters: [] as any,
|
|
||||||
result: [] as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res).toEqual({
|
|
||||||
data: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getController", async () => {
|
describe("getController", async () => {
|
||||||
const users = new Entity("users", [
|
const users = new Entity("users", [
|
||||||
new TextField("name", { required: true }),
|
new TextField("name", { required: true }),
|
||||||
@@ -120,8 +74,7 @@ describe("[data] DataController", async () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(_user),
|
body: JSON.stringify(_user),
|
||||||
});
|
});
|
||||||
//console.log("res", { _user }, res);
|
const result = (await res.json()) as MutatorResultJSON;
|
||||||
const result = (await res.json()) as MutatorResponse;
|
|
||||||
const { id, ...data } = result.data as any;
|
const { id, ...data } = result.data as any;
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
@@ -135,7 +88,7 @@ describe("[data] DataController", async () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(_post),
|
body: JSON.stringify(_post),
|
||||||
});
|
});
|
||||||
const result = (await res.json()) as MutatorResponse;
|
const result = (await res.json()) as MutatorResultJSON;
|
||||||
const { id, ...data } = result.data as any;
|
const { id, ...data } = result.data as any;
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
@@ -146,13 +99,13 @@ describe("[data] DataController", async () => {
|
|||||||
|
|
||||||
test("/:entity (read many)", async () => {
|
test("/:entity (read many)", async () => {
|
||||||
const res = await app.request("/entity/users");
|
const res = await app.request("/entity/users");
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResultJSON;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
//expect(data.meta.total).toBe(3);
|
||||||
expect(data.meta.count).toBe(3);
|
//expect(data.meta.count).toBe(3);
|
||||||
expect(data.meta.items).toBe(3);
|
expect(data.meta.items).toBe(3);
|
||||||
expect(data.data.length).toBe(3);
|
expect(data.data.length).toBe(3);
|
||||||
expect(data.data[0].name).toBe("foo");
|
expect(data.data[0]?.name).toBe("foo");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/query (func query)", async () => {
|
test("/:entity/query (func query)", async () => {
|
||||||
@@ -165,33 +118,32 @@ describe("[data] DataController", async () => {
|
|||||||
where: { bio: { $isnull: 1 } },
|
where: { bio: { $isnull: 1 } },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as RepositoryResponse;
|
const data = (await res.json()) as RepositoryResultJSON;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
//expect(data.meta.total).toBe(3);
|
||||||
expect(data.meta.count).toBe(1);
|
//expect(data.meta.count).toBe(1);
|
||||||
expect(data.meta.items).toBe(1);
|
expect(data.meta.items).toBe(1);
|
||||||
expect(data.data.length).toBe(1);
|
expect(data.data.length).toBe(1);
|
||||||
expect(data.data[0].name).toBe("bar");
|
expect(data.data[0]?.name).toBe("bar");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity (read many, paginated)", async () => {
|
test("/:entity (read many, paginated)", async () => {
|
||||||
const res = await app.request("/entity/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 RepositoryResultJSON;
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
//expect(data.meta.total).toBe(3);
|
||||||
expect(data.meta.count).toBe(3);
|
//expect(data.meta.count).toBe(3);
|
||||||
expect(data.meta.items).toBe(1);
|
expect(data.meta.items).toBe(1);
|
||||||
expect(data.data.length).toBe(1);
|
expect(data.data.length).toBe(1);
|
||||||
expect(data.data[0].name).toBe("baz");
|
expect(data.data[0]?.name).toBe("baz");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id (read one)", async () => {
|
test("/:entity/:id (read one)", async () => {
|
||||||
const res = await app.request("/entity/users/3");
|
const res = await app.request("/entity/users/3");
|
||||||
const data = (await res.json()) as RepositoryResponse<EntityData>;
|
const data = (await res.json()) as RepositoryResultJSON<EntityData>;
|
||||||
console.log("data", data);
|
|
||||||
|
|
||||||
expect(data.meta.total).toBe(3);
|
//expect(data.meta.total).toBe(3);
|
||||||
expect(data.meta.count).toBe(1);
|
//expect(data.meta.count).toBe(1);
|
||||||
expect(data.meta.items).toBe(1);
|
expect(data.meta.items).toBe(1);
|
||||||
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
|
expect(data.data).toEqual({ id: 3, ...fixtures.users[2] });
|
||||||
});
|
});
|
||||||
@@ -201,7 +153,7 @@ describe("[data] DataController", async () => {
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ name: "new name" }),
|
body: JSON.stringify({ name: "new name" }),
|
||||||
});
|
});
|
||||||
const { data } = (await res.json()) as MutatorResponse;
|
const { data } = (await res.json()) as MutatorResultJSON;
|
||||||
|
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
|
expect(data as any).toEqual({ id: 3, ...fixtures.users[2], name: "new name" });
|
||||||
@@ -209,27 +161,26 @@ describe("[data] DataController", async () => {
|
|||||||
|
|
||||||
test("/:entity/:id/:reference (read references)", async () => {
|
test("/:entity/:id/:reference (read references)", async () => {
|
||||||
const res = await app.request("/entity/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 RepositoryResultJSON;
|
||||||
console.log("data", data);
|
|
||||||
|
|
||||||
expect(data.meta.total).toBe(2);
|
//expect(data.meta.total).toBe(2);
|
||||||
expect(data.meta.count).toBe(1);
|
//expect(data.meta.count).toBe(1);
|
||||||
expect(data.meta.items).toBe(1);
|
expect(data.meta.items).toBe(1);
|
||||||
expect(data.data.length).toBe(1);
|
expect(data.data.length).toBe(1);
|
||||||
expect(data.data[0].content).toBe("post 1");
|
expect(data.data[0]?.content).toBe("post 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("/:entity/:id (delete one)", async () => {
|
test("/:entity/:id (delete one)", async () => {
|
||||||
const res = await app.request("/entity/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 RepositoryResultJSON<EntityData>;
|
||||||
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
expect(data).toEqual({ id: 2, ...fixtures.posts[1] });
|
||||||
|
|
||||||
// verify
|
// verify
|
||||||
const res2 = await app.request("/entity/posts");
|
const res2 = await app.request("/entity/posts");
|
||||||
const data2 = (await res2.json()) as RepositoryResponse;
|
const data2 = (await res2.json()) as RepositoryResultJSON;
|
||||||
expect(data2.meta.total).toBe(1);
|
//expect(data2.meta.total).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,19 +34,12 @@ describe("some tests", async () => {
|
|||||||
|
|
||||||
test("findId", async () => {
|
test("findId", async () => {
|
||||||
const query = await em.repository(users).findId(1);
|
const query = await em.repository(users).findId(1);
|
||||||
/*const { result, total, count, time } = query;
|
|
||||||
console.log("query", query.result, {
|
|
||||||
result,
|
|
||||||
total,
|
|
||||||
count,
|
|
||||||
time,
|
|
||||||
});*/
|
|
||||||
|
|
||||||
expect(query.sql).toBe(
|
expect(query.sql).toBe(
|
||||||
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
|
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" where "id" = ? limit ?',
|
||||||
);
|
);
|
||||||
expect(query.parameters).toEqual([1, 1]);
|
expect(query.parameters).toEqual([1, 1]);
|
||||||
expect(query.result).toEqual([]);
|
expect(query.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findMany", async () => {
|
test("findMany", async () => {
|
||||||
@@ -56,7 +49,7 @@ describe("some tests", async () => {
|
|||||||
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
|
'select "users"."id" as "id", "users"."username" as "username", "users"."email" as "email" from "users" order by "users"."id" asc limit ? offset ?',
|
||||||
);
|
);
|
||||||
expect(query.parameters).toEqual([10, 0]);
|
expect(query.parameters).toEqual([10, 0]);
|
||||||
expect(query.result).toEqual([]);
|
expect(query.data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findMany with number", async () => {
|
test("findMany with number", async () => {
|
||||||
@@ -66,7 +59,7 @@ describe("some tests", async () => {
|
|||||||
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
|
'select "posts"."id" as "id", "posts"."title" as "title", "posts"."content" as "content", "posts"."created_at" as "created_at", "posts"."likes" as "likes" from "posts" order by "posts"."id" asc limit ? offset ?',
|
||||||
);
|
);
|
||||||
expect(query.parameters).toEqual([10, 0]);
|
expect(query.parameters).toEqual([10, 0]);
|
||||||
expect(query.result).toEqual([]);
|
expect(query.data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("try adding an existing field name", async () => {
|
test("try adding an existing field name", async () => {
|
||||||
@@ -110,4 +103,18 @@ describe("some tests", async () => {
|
|||||||
new EntityManager([entity, entity2], connection);
|
new EntityManager([entity, entity2], connection);
|
||||||
}).toThrow();
|
}).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("primary uuid", async () => {
|
||||||
|
const entity = new Entity("users", [
|
||||||
|
new PrimaryField("id", { format: "uuid" }),
|
||||||
|
new TextField("username"),
|
||||||
|
]);
|
||||||
|
const em = new EntityManager([entity], getDummyConnection().dummyConnection);
|
||||||
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
const mutator = em.mutator(entity);
|
||||||
|
const data = await mutator.insertOne({ username: "test" });
|
||||||
|
expect(data.data.id).toBeDefined();
|
||||||
|
expect(data.data.id).toBeString();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ describe("Mutator simple", async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(query.result).toEqual([{ id: 1, label: "test", count: 1 }]);
|
expect(query.data).toEqual([{ id: 1, label: "test", count: 1 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("update inserted row", async () => {
|
test("update inserted row", async () => {
|
||||||
@@ -87,7 +87,7 @@ describe("Mutator simple", async () => {
|
|||||||
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
|
expect(mutation.data).toEqual({ id, label: "new label", count: 100 });
|
||||||
|
|
||||||
const query2 = await em.repository(items).findId(id);
|
const query2 = await em.repository(items).findId(id);
|
||||||
expect(query2.result.length).toBe(0);
|
expect(query2.data).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("validation: insert incomplete row", async () => {
|
test("validation: insert incomplete row", async () => {
|
||||||
@@ -177,13 +177,13 @@ describe("Mutator simple", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("insertMany", async () => {
|
test("insertMany", async () => {
|
||||||
const oldCount = (await em.repo(items).count()).count;
|
const oldCount = (await em.repo(items).count()).data.count;
|
||||||
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
|
const inserts = [{ label: "insert 1" }, { label: "insert 2" }];
|
||||||
const { data } = await em.mutator(items).insertMany(inserts);
|
const { data } = await em.mutator(items).insertMany(inserts);
|
||||||
|
|
||||||
expect(data.length).toBe(2);
|
expect(data.length).toBe(2);
|
||||||
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
|
expect(data.map((d) => ({ label: d.label }))).toEqual(inserts);
|
||||||
const newCount = (await em.repo(items).count()).count;
|
const newCount = (await em.repo(items).count()).data.count;
|
||||||
expect(newCount).toBe(oldCount + inserts.length);
|
expect(newCount).toBe(oldCount + inserts.length);
|
||||||
|
|
||||||
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
|
const { data: data2 } = await em.repo(items).findMany({ offset: oldCount });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Entity, NumberField, TextField } from "../../../src/data";
|
import { Entity, NumberField, TextField } from "data";
|
||||||
|
import * as p from "data/prototype";
|
||||||
|
|
||||||
describe("[data] Entity", async () => {
|
describe("[data] Entity", async () => {
|
||||||
const entity = new Entity("test", [
|
const entity = new Entity("test", [
|
||||||
@@ -47,14 +48,7 @@ describe("[data] Entity", async () => {
|
|||||||
expect(entity.getField("new_field")).toBe(field);
|
expect(entity.getField("new_field")).toBe(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
// @todo: move this to ClientApp
|
test.only("types", async () => {
|
||||||
/*test("serialize and deserialize", async () => {
|
console.log(entity.toTypes());
|
||||||
const json = entity.toJSON();
|
});
|
||||||
//sconsole.log("json", json.fields);
|
|
||||||
const newEntity = Entity.deserialize(json);
|
|
||||||
//console.log("newEntity", newEntity.toJSON().fields);
|
|
||||||
expect(newEntity).toBeInstanceOf(Entity);
|
|
||||||
expect(json).toEqual(newEntity.toJSON());
|
|
||||||
expect(json.fields).toEqual(newEntity.toJSON().fields);
|
|
||||||
});*/
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ describe("[data] EntityManager", async () => {
|
|||||||
em.addRelation(new ManyToOneRelation(posts, users));
|
em.addRelation(new ManyToOneRelation(posts, users));
|
||||||
expect(em.relations.all.length).toBe(1);
|
expect(em.relations.all.length).toBe(1);
|
||||||
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
|
expect(em.relations.all[0]).toBeInstanceOf(ManyToOneRelation);
|
||||||
expect(em.relationsOf("users")).toEqual([em.relations.all[0]]);
|
expect(em.relationsOf("users")).toEqual([em.relations.all[0]!]);
|
||||||
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]]);
|
expect(em.relationsOf("posts")).toEqual([em.relations.all[0]!]);
|
||||||
expect(em.hasRelations("users")).toBe(true);
|
expect(em.hasRelations("users")).toBe(true);
|
||||||
expect(em.hasRelations("posts")).toBe(true);
|
expect(em.hasRelations("posts")).toBe(true);
|
||||||
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
|
expect(em.relatedEntitiesOf("users")).toEqual([posts]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import type { EventManager } from "../../../src/core/events";
|
import type { EventManager } from "../../../src/core/events";
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
@@ -12,11 +12,14 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
} from "../../../src/data";
|
} from "../../../src/data";
|
||||||
import * as proto from "../../../src/data/prototype";
|
import * as proto from "../../../src/data/prototype";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection, disableConsoleLog, enableConsoleLog } from "../../helper";
|
||||||
|
|
||||||
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
const { dummyConnection, afterAllCleanup } = getDummyConnection();
|
||||||
afterAll(afterAllCleanup);
|
afterAll(afterAllCleanup);
|
||||||
|
|
||||||
|
beforeAll(() => disableConsoleLog(["log", "warn"]));
|
||||||
|
afterAll(async () => (await afterAllCleanup()) && enableConsoleLog());
|
||||||
|
|
||||||
describe("[data] Mutator (base)", async () => {
|
describe("[data] Mutator (base)", async () => {
|
||||||
const entity = new Entity("items", [
|
const entity = new Entity("items", [
|
||||||
new TextField("label", { required: true }),
|
new TextField("label", { required: true }),
|
||||||
|
|||||||
@@ -26,120 +26,6 @@ async function sleep(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("[Repository]", async () => {
|
describe("[Repository]", async () => {
|
||||||
test.skip("bulk", async () => {
|
|
||||||
//const connection = dummyConnection;
|
|
||||||
//const connection = getLocalLibsqlConnection();
|
|
||||||
const credentials = null as any; // @todo: determine what to do here
|
|
||||||
const connection = new LibsqlConnection(credentials);
|
|
||||||
|
|
||||||
const em = new EntityManager([], connection);
|
|
||||||
/*const emLibsql = new EntityManager([], {
|
|
||||||
url: connection.url.replace("https", "libsql"),
|
|
||||||
authToken: connection.authToken,
|
|
||||||
});*/
|
|
||||||
const table = "posts";
|
|
||||||
|
|
||||||
const client = connection.getClient();
|
|
||||||
if (!client) {
|
|
||||||
console.log("Cannot perform test without libsql connection");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = em.connection.kysely;
|
|
||||||
const selectQ = (e: E) => e.selectFrom(table).selectAll().limit(2);
|
|
||||||
const countQ = (e: E) => e.selectFrom(table).select(e.fn.count("*").as("count"));
|
|
||||||
|
|
||||||
async function executeTransaction(em: EntityManager<any>) {
|
|
||||||
return await em.connection.kysely.transaction().execute(async (e) => {
|
|
||||||
const res = await selectQ(e).execute();
|
|
||||||
const count = await countQ(e).execute();
|
|
||||||
|
|
||||||
return [res, count];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeBatch(em: EntityManager<any>) {
|
|
||||||
const queries = [selectQ(conn), countQ(conn)];
|
|
||||||
return await em.connection.batchQuery(queries);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeSingleKysely(em: EntityManager<any>) {
|
|
||||||
const res = await selectQ(conn).execute();
|
|
||||||
const count = await countQ(conn).execute();
|
|
||||||
return [res, count];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeSingleClient(em: EntityManager<any>) {
|
|
||||||
const q1 = selectQ(conn).compile();
|
|
||||||
const res = await client.execute({
|
|
||||||
sql: q1.sql,
|
|
||||||
args: q1.parameters as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
const q2 = countQ(conn).compile();
|
|
||||||
const count = await client.execute({
|
|
||||||
sql: q2.sql,
|
|
||||||
args: q2.parameters as any,
|
|
||||||
});
|
|
||||||
return [res, count];
|
|
||||||
}
|
|
||||||
|
|
||||||
const transaction = await executeTransaction(em);
|
|
||||||
const batch = await executeBatch(em);
|
|
||||||
|
|
||||||
expect(batch).toEqual(transaction as any);
|
|
||||||
|
|
||||||
const testperf = false;
|
|
||||||
if (testperf) {
|
|
||||||
const times = 5;
|
|
||||||
|
|
||||||
const exec = async (
|
|
||||||
name: string,
|
|
||||||
fn: (em: EntityManager<any>) => Promise<any>,
|
|
||||||
em: EntityManager<any>,
|
|
||||||
) => {
|
|
||||||
const res = await Perf.execute(() => fn(em), times);
|
|
||||||
await sleep(1000);
|
|
||||||
const info = {
|
|
||||||
name,
|
|
||||||
total: res.total.toFixed(2),
|
|
||||||
avg: (res.total / times).toFixed(2),
|
|
||||||
first: res.marks[0].time.toFixed(2),
|
|
||||||
last: res.marks[res.marks.length - 1].time.toFixed(2),
|
|
||||||
};
|
|
||||||
console.log(info.name, info, res.marks);
|
|
||||||
return info;
|
|
||||||
};
|
|
||||||
|
|
||||||
const data: any[] = [];
|
|
||||||
data.push(await exec("transaction.http", executeTransaction, em));
|
|
||||||
data.push(await exec("bulk.http", executeBatch, em));
|
|
||||||
data.push(await exec("singleKy.http", executeSingleKysely, em));
|
|
||||||
data.push(await exec("singleCl.http", executeSingleClient, em));
|
|
||||||
|
|
||||||
/*data.push(await exec("transaction.libsql", executeTransaction, emLibsql));
|
|
||||||
data.push(await exec("bulk.libsql", executeBatch, emLibsql));
|
|
||||||
data.push(await exec("singleKy.libsql", executeSingleKysely, emLibsql));
|
|
||||||
data.push(await exec("singleCl.libsql", executeSingleClient, emLibsql));*/
|
|
||||||
|
|
||||||
console.table(data);
|
|
||||||
/**
|
|
||||||
* ┌───┬────────────────────┬────────┬────────┬────────┬────────┐
|
|
||||||
* │ │ name │ total │ avg │ first │ last │
|
|
||||||
* ├───┼────────────────────┼────────┼────────┼────────┼────────┤
|
|
||||||
* │ 0 │ transaction.http │ 681.29 │ 136.26 │ 136.46 │ 396.09 │
|
|
||||||
* │ 1 │ bulk.http │ 164.82 │ 32.96 │ 32.95 │ 99.91 │
|
|
||||||
* │ 2 │ singleKy.http │ 330.01 │ 66.00 │ 65.86 │ 195.41 │
|
|
||||||
* │ 3 │ singleCl.http │ 326.17 │ 65.23 │ 61.32 │ 198.08 │
|
|
||||||
* │ 4 │ transaction.libsql │ 856.79 │ 171.36 │ 132.31 │ 595.24 │
|
|
||||||
* │ 5 │ bulk.libsql │ 180.63 │ 36.13 │ 35.39 │ 107.71 │
|
|
||||||
* │ 6 │ singleKy.libsql │ 347.11 │ 69.42 │ 65.00 │ 207.14 │
|
|
||||||
* │ 7 │ singleCl.libsql │ 328.60 │ 65.72 │ 62.19 │ 195.04 │
|
|
||||||
* └───┴────────────────────┴────────┴────────┴────────┴────────┘
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("count & exists", async () => {
|
test("count & exists", async () => {
|
||||||
const items = new Entity("items", [new TextField("label")]);
|
const items = new Entity("items", [new TextField("label")]);
|
||||||
const em = new EntityManager([items], dummyConnection);
|
const em = new EntityManager([items], dummyConnection);
|
||||||
@@ -160,25 +46,44 @@ describe("[Repository]", async () => {
|
|||||||
// count all
|
// count all
|
||||||
const res = await em.repository(items).count();
|
const res = await em.repository(items).count();
|
||||||
expect(res.sql).toBe('select count(*) as "count" from "items"');
|
expect(res.sql).toBe('select count(*) as "count" from "items"');
|
||||||
|
expect(res.data.count).toBe(3);
|
||||||
|
|
||||||
|
//
|
||||||
|
{
|
||||||
|
const res = await em.repository(items).findMany();
|
||||||
expect(res.count).toBe(3);
|
expect(res.count).toBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const res = await em
|
||||||
|
.repository(items, {
|
||||||
|
includeCounts: true,
|
||||||
|
})
|
||||||
|
.findMany();
|
||||||
|
expect(res.count).toBe(3);
|
||||||
|
}
|
||||||
|
|
||||||
// count filtered
|
// count filtered
|
||||||
const res2 = await em.repository(items).count({ label: { $in: ["a", "b"] } });
|
const res2 = await em
|
||||||
|
.repository(items, {
|
||||||
|
includeCounts: true,
|
||||||
|
})
|
||||||
|
.count({ label: { $in: ["a", "b"] } });
|
||||||
|
|
||||||
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
|
expect(res2.sql).toBe('select count(*) as "count" from "items" where "label" in (?, ?)');
|
||||||
expect(res2.parameters).toEqual(["a", "b"]);
|
expect(res2.parameters).toEqual(["a", "b"]);
|
||||||
expect(res2.count).toBe(2);
|
expect(res2.data.count).toBe(2);
|
||||||
|
|
||||||
// check exists
|
// check exists
|
||||||
const res3 = await em.repository(items).exists({ label: "a" });
|
const res3 = await em.repository(items).exists({ label: "a" });
|
||||||
expect(res3.exists).toBe(true);
|
expect(res3.data.exists).toBe(true);
|
||||||
|
|
||||||
const res4 = await em.repository(items).exists({ label: "d" });
|
const res4 = await em.repository(items).exists({ label: "d" });
|
||||||
expect(res4.exists).toBe(false);
|
expect(res4.data.exists).toBe(false);
|
||||||
|
|
||||||
// for now, allow empty filter
|
// for now, allow empty filter
|
||||||
const res5 = await em.repository(items).exists({});
|
const res5 = await em.repository(items).exists({});
|
||||||
expect(res5.exists).toBe(true);
|
expect(res5.data.exists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("option: silent", async () => {
|
test("option: silent", async () => {
|
||||||
@@ -191,6 +96,9 @@ describe("[Repository]", async () => {
|
|||||||
// should throw because table doesn't exist
|
// should throw because table doesn't exist
|
||||||
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
|
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
|
||||||
// should silently return empty result
|
// should silently return empty result
|
||||||
|
em.repo("items", { silent: true })
|
||||||
|
.findMany({})
|
||||||
|
.then((r) => r.data);
|
||||||
expect(
|
expect(
|
||||||
em
|
em
|
||||||
.repo("items", { silent: true })
|
.repo("items", { silent: true })
|
||||||
@@ -209,16 +117,16 @@ describe("[Repository]", async () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
em
|
em
|
||||||
.repo("items")
|
.repo("items", { includeCounts: true })
|
||||||
.findMany({})
|
.findMany({})
|
||||||
.then((r) => [r.meta.count, r.meta.total]),
|
.then((r) => [r.count, r.total]),
|
||||||
).resolves.toEqual([0, 0]);
|
).resolves.toEqual([0, 0]);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
em
|
em
|
||||||
.repo("items", { includeCounts: false })
|
.repo("items", { includeCounts: false })
|
||||||
.findMany({})
|
.findMany({})
|
||||||
.then((r) => [r.meta.count, r.meta.total]),
|
.then((r) => [r.count, r.total]),
|
||||||
).resolves.toEqual([undefined, undefined]);
|
).resolves.toEqual([undefined, undefined]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -266,5 +174,12 @@ describe("[data] Repository (Events)", async () => {
|
|||||||
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
|
expect(events.has(RepositoryEvents.RepositoryFindManyBefore.slug)).toBeTrue();
|
||||||
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
|
expect(events.has(RepositoryEvents.RepositoryFindManyAfter.slug)).toBeTrue();
|
||||||
events.clear();
|
events.clear();
|
||||||
|
|
||||||
|
// check find one on findMany with limit 1
|
||||||
|
await repo.findMany({ where: { id: 1 }, limit: 1 });
|
||||||
|
await repo.emgr.executeAsyncs();
|
||||||
|
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
|
||||||
|
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
|
||||||
|
events.clear();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-71
@@ -1,15 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { Value, _jsonp } from "../../src/core/utils";
|
import { getDummyConnection } from "../helper";
|
||||||
import { type RepoQuery, WhereBuilder, type WhereQuery, querySchema } from "../../src/data";
|
import { type WhereQuery, WhereBuilder } from "data";
|
||||||
import type { RepoQueryIn } from "../../src/data/server/data-query-impl";
|
|
||||||
import { getDummyConnection } from "./helper";
|
|
||||||
|
|
||||||
const decode = (input: RepoQueryIn, expected: RepoQuery) => {
|
|
||||||
const result = Value.Decode(querySchema, input);
|
|
||||||
expect(result).toEqual(expected);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("data-query-impl", () => {
|
|
||||||
function qb() {
|
function qb() {
|
||||||
const c = getDummyConnection();
|
const c = getDummyConnection();
|
||||||
const kysely = c.dummyConnection.kysely;
|
const kysely = c.dummyConnection.kysely;
|
||||||
@@ -20,6 +12,7 @@ describe("data-query-impl", () => {
|
|||||||
return { sql, parameters };
|
return { sql, parameters };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("WhereBuilder", () => {
|
||||||
test("single validation", () => {
|
test("single validation", () => {
|
||||||
const tests: [WhereQuery, string, any[]][] = [
|
const tests: [WhereQuery, string, any[]][] = [
|
||||||
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
[{ name: "Michael", age: 40 }, '("name" = ? and "age" = ?)', ["Michael", 40]],
|
||||||
@@ -94,64 +87,4 @@ describe("data-query-impl", () => {
|
|||||||
expect(keys).toEqual(expectedKeys);
|
expect(keys).toEqual(expectedKeys);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("with", () => {
|
|
||||||
decode({ with: ["posts"] }, { with: { posts: {} } });
|
|
||||||
decode({ with: { posts: {} } }, { with: { posts: {} } });
|
|
||||||
decode({ with: { posts: { limit: 1 } } }, { with: { posts: { limit: 1 } } });
|
|
||||||
decode(
|
|
||||||
{
|
|
||||||
with: {
|
|
||||||
posts: {
|
|
||||||
with: {
|
|
||||||
images: {
|
|
||||||
select: ["id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
with: {
|
|
||||||
posts: {
|
|
||||||
with: {
|
|
||||||
images: {
|
|
||||||
select: ["id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("data-query-impl: Typebox", () => {
|
|
||||||
test("sort", async () => {
|
|
||||||
const _dflt = { sort: { by: "id", dir: "asc" } };
|
|
||||||
|
|
||||||
decode({ sort: "" }, _dflt);
|
|
||||||
decode({ sort: "name" }, { sort: { by: "name", dir: "asc" } });
|
|
||||||
decode({ sort: "-name" }, { sort: { by: "name", dir: "desc" } });
|
|
||||||
decode({ sort: "-posts.name" }, { sort: { by: "posts.name", dir: "desc" } });
|
|
||||||
decode({ sort: "-1name" }, _dflt);
|
|
||||||
decode({ sort: { by: "name", dir: "desc" } }, { sort: { by: "name", dir: "desc" } });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -89,9 +89,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
const res2 = qb2.compile();
|
const res2 = qb2.compile();
|
||||||
|
|
||||||
expect(res2.sql).toBe(
|
expect(res2.sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ? offset ?) as obj) as "author" from "posts"',
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username") from (select "users"."id" as "id", "users"."username" as "username" from "users" as "author" where "author"."id" = "posts"."author_id" order by "users"."id" asc limit ?) as obj) as "author" from "posts"',
|
||||||
);
|
);
|
||||||
expect(res2.parameters).toEqual([1, 0]);
|
expect(res2.parameters).toEqual([1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("test with empty join", async () => {
|
test("test with empty join", async () => {
|
||||||
@@ -194,9 +194,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
);
|
);
|
||||||
const res = qb.compile();
|
const res = qb.compile();
|
||||||
expect(res.sql).toBe(
|
expect(res.sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "single" from "categories"',
|
'select (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "categories"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "single" from "categories"',
|
||||||
);
|
);
|
||||||
expect(res.parameters).toEqual(["categories.single", 1, 0]);
|
expect(res.parameters).toEqual(["categories.single", 1]);
|
||||||
|
|
||||||
const qb2 = WithBuilder.addClause(
|
const qb2 = WithBuilder.addClause(
|
||||||
em,
|
em,
|
||||||
@@ -273,9 +273,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
|
|
||||||
//prettyPrintQb(qb);
|
//prettyPrintQb(qb);
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toBe(
|
||||||
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ? offset ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ? offset ?) as obj) as "users" from "posts"',
|
'select (select json_object(\'id\', "obj"."id", \'username\', "obj"."username", \'avatar\', "obj"."avatar") from (select "users"."id" as "id", "users"."username" as "username", (select json_object(\'id\', "obj"."id", \'path\', "obj"."path") from (select "media"."id" as "id", "media"."path" as "path" from "media" where "media"."reference" = ? and "users"."id" = "media"."entity_id" order by "media"."id" asc limit ?) as obj) as "avatar" from "users" as "users" where "users"."id" = "posts"."users_id" order by "users"."username" asc limit ?) as obj) as "users" from "posts"',
|
||||||
);
|
);
|
||||||
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 0, 1, 0]);
|
expect(qb.compile().parameters).toEqual(["users.avatar", 1, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("compiles with many", async () => {
|
test("compiles with many", async () => {
|
||||||
@@ -315,9 +315,9 @@ describe("[data] WithBuilder", async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(qb.compile().sql).toBe(
|
expect(qb.compile().sql).toBe(
|
||||||
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ? offset ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
|
'select (select coalesce(json_group_array(json_object(\'id\', "agg"."id", \'posts_id\', "agg"."posts_id", \'users_id\', "agg"."users_id", \'users\', "agg"."users")), \'[]\') from (select "comments"."id" as "id", "comments"."posts_id" as "posts_id", "comments"."users_id" as "users_id", (select json_object(\'username\', "obj"."username") from (select "users"."username" as "username" from "users" as "users" where "users"."id" = "comments"."users_id" order by "users"."id" asc limit ?) as obj) as "users" from "comments" as "comments" where "comments"."posts_id" = "posts"."id" order by "comments"."id" asc limit ? offset ?) as agg) as "comments" from "posts"',
|
||||||
);
|
);
|
||||||
expect(qb.compile().parameters).toEqual([1, 0, 12, 0]);
|
expect(qb.compile().parameters).toEqual([1, 12, 0]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns correct result", async () => {
|
test("returns correct result", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Type } from "../../../../src/core/utils";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { Entity, EntityIndex, Field } from "../../../../src/data";
|
import { Entity, EntityIndex, Field } from "../../../../src/data";
|
||||||
|
|
||||||
class TestField extends Field {
|
class TestField extends Field {
|
||||||
|
|||||||
@@ -39,4 +39,28 @@ describe("[data] PrimaryField", async () => {
|
|||||||
expect(field.transformPersist(1)).rejects.toThrow();
|
expect(field.transformPersist(1)).rejects.toThrow();
|
||||||
expect(field.transformRetrieve(1)).toBe(1);
|
expect(field.transformRetrieve(1)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("format", () => {
|
||||||
|
const uuid = new PrimaryField("uuid", { format: "uuid" });
|
||||||
|
expect(uuid.format).toBe("uuid");
|
||||||
|
expect(uuid.fieldType).toBe("text");
|
||||||
|
expect(uuid.getNewValue()).toBeString();
|
||||||
|
expect(uuid.toType()).toEqual({
|
||||||
|
required: true,
|
||||||
|
comment: undefined,
|
||||||
|
type: "Generated<string>",
|
||||||
|
import: [{ package: "kysely", name: "Generated" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const integer = new PrimaryField("integer", { format: "integer" });
|
||||||
|
expect(integer.format).toBe("integer");
|
||||||
|
expect(integer.fieldType).toBe("integer");
|
||||||
|
expect(integer.getNewValue()).toBeUndefined();
|
||||||
|
expect(integer.toType()).toEqual({
|
||||||
|
required: true,
|
||||||
|
comment: undefined,
|
||||||
|
type: "Generated<number>",
|
||||||
|
import: [{ package: "kysely", name: "Generated" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Flow, LogTask, RenderTask, SubFlowTask } from "../../src/flows";
|
import { Flow, LogTask, SubFlowTask, RenderTask, Task } from "../../src/flows";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
|
export class StringifyTask<Output extends string> extends Task<
|
||||||
|
typeof StringifyTask.schema,
|
||||||
|
Output
|
||||||
|
> {
|
||||||
|
type = "stringify";
|
||||||
|
|
||||||
|
static override schema = Type.Optional(
|
||||||
|
Type.Object({
|
||||||
|
input: Type.Optional(Type.String()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async execute() {
|
||||||
|
return JSON.stringify(this.params.input) as Output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe("SubFlowTask", async () => {
|
describe("SubFlowTask", async () => {
|
||||||
test("Simple Subflow", async () => {
|
test("Simple Subflow", async () => {
|
||||||
@@ -22,8 +40,6 @@ describe("SubFlowTask", async () => {
|
|||||||
|
|
||||||
const execution = flow.createExecution();
|
const execution = flow.createExecution();
|
||||||
await execution.start();
|
await execution.start();
|
||||||
/*console.log(execution.logs);
|
|
||||||
console.log(execution.getResponse());*/
|
|
||||||
|
|
||||||
expect(execution.getResponse()).toEqual("Subflow output: subflow");
|
expect(execution.getResponse()).toEqual("Subflow output: subflow");
|
||||||
});
|
});
|
||||||
@@ -40,8 +56,8 @@ describe("SubFlowTask", async () => {
|
|||||||
loop: true,
|
loop: true,
|
||||||
input: [1, 2, 3],
|
input: [1, 2, 3],
|
||||||
});
|
});
|
||||||
const task3 = new RenderTask("render2", {
|
const task3 = new StringifyTask("stringify", {
|
||||||
render: `Subflow output: {{ sub.output | join: ", " }}`,
|
input: "{{ sub.output }}",
|
||||||
});
|
});
|
||||||
|
|
||||||
const flow = new Flow("test", [task, task2, task3], []);
|
const flow = new Flow("test", [task, task2, task3], []);
|
||||||
@@ -51,41 +67,6 @@ describe("SubFlowTask", async () => {
|
|||||||
const execution = flow.createExecution();
|
const execution = flow.createExecution();
|
||||||
await execution.start();
|
await execution.start();
|
||||||
|
|
||||||
console.log("errors", execution.getErrors());
|
expect(execution.getResponse()).toEqual('"run 1,run 2,run 3"');
|
||||||
|
|
||||||
/*console.log(execution.logs);
|
|
||||||
console.log(execution.getResponse());*/
|
|
||||||
|
|
||||||
expect(execution.getResponse()).toEqual("Subflow output: run 1, run 2, run 3");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Simple loop from flow input", async () => {
|
|
||||||
const subTask = new RenderTask("render", {
|
|
||||||
render: "run {{ flow.output }}",
|
|
||||||
});
|
|
||||||
|
|
||||||
const subflow = new Flow("subflow", [subTask]);
|
|
||||||
|
|
||||||
const task = new LogTask("log");
|
|
||||||
const task2 = new SubFlowTask("sub", {
|
|
||||||
flow: subflow,
|
|
||||||
loop: true,
|
|
||||||
input: "{{ flow.output | json }}",
|
|
||||||
});
|
|
||||||
const task3 = new RenderTask("render2", {
|
|
||||||
render: `Subflow output: {{ sub.output | join: ", " }}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flow = new Flow("test", [task, task2, task3], []);
|
|
||||||
flow.task(task).asInputFor(task2);
|
|
||||||
flow.task(task2).asInputFor(task3);
|
|
||||||
|
|
||||||
const execution = flow.createExecution();
|
|
||||||
await execution.start([4, 5, 6]);
|
|
||||||
|
|
||||||
/*console.log(execution.logs);
|
|
||||||
console.log(execution.getResponse());*/
|
|
||||||
|
|
||||||
expect(execution.getResponse()).toEqual("Subflow output: run 4, run 5, run 6");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Type } from "../../src/core/utils";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { Task } from "../../src/flows";
|
import { Task } from "../../src/flows";
|
||||||
import { dynamic } from "../../src/flows/tasks/Task";
|
import { dynamic } from "../../src/flows/tasks/Task";
|
||||||
|
|
||||||
@@ -51,62 +51,4 @@ describe("Task", async () => {
|
|||||||
|
|
||||||
expect(result.test).toEqual({ key: "path", value: "1/1" });
|
expect(result.test).toEqual({ key: "path", value: "1/1" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveParams: with json", async () => {
|
|
||||||
const result = await Task.resolveParams(
|
|
||||||
Type.Object({
|
|
||||||
test: dynamic(Type.Object({ key: Type.String(), value: Type.String() })),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
test: "{{ some | json }}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
some: {
|
|
||||||
key: "path",
|
|
||||||
value: "1/1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.test).toEqual({ key: "path", value: "1/1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveParams: with array", async () => {
|
|
||||||
const result = await Task.resolveParams(
|
|
||||||
Type.Object({
|
|
||||||
test: dynamic(Type.Array(Type.String())),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
test: '{{ "1,2,3" | split: "," | json }}',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.test).toEqual(["1", "2", "3"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveParams: boolean", async () => {
|
|
||||||
const result = await Task.resolveParams(
|
|
||||||
Type.Object({
|
|
||||||
test: dynamic(Type.Boolean()),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
test: "{{ true }}",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.test).toEqual(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resolveParams: float", async () => {
|
|
||||||
const result = await Task.resolveParams(
|
|
||||||
Type.Object({
|
|
||||||
test: dynamic(Type.Number(), Number.parseFloat),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
test: "{{ 3.14 }}",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.test).toEqual(3.14);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Event, EventManager } from "../../src/core/events";
|
import { Event, EventManager } from "../../src/core/events";
|
||||||
import { type Static, type StaticDecode, Type, parse } from "../../src/core/utils";
|
import { parse } from "../../src/core/utils";
|
||||||
|
import { type Static, type StaticDecode, Type } from "@sinclair/typebox";
|
||||||
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
|
import { EventTrigger, Flow, HttpTrigger, type InputsMap, Task } from "../../src/flows";
|
||||||
import { dynamic } from "../../src/flows/tasks/Task";
|
import { dynamic } from "../../src/flows/tasks/Task";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// eslint-disable-next-line import/no-unresolved
|
// eslint-disable-next-line import/no-unresolved
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { isEqual } from "lodash-es";
|
import { isEqual } from "lodash-es";
|
||||||
import { type Static, Type, _jsonp, withDisabledConsole } from "../../src/core/utils";
|
import { _jsonp, withDisabledConsole } from "../../src/core/utils";
|
||||||
|
import { type Static, Type } from "@sinclair/typebox";
|
||||||
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
import { Condition, ExecutionEvent, FetchTask, Flow, LogTask, Task } from "../../src/flows";
|
||||||
|
|
||||||
/*beforeAll(disableConsoleLog);
|
/*beforeAll(disableConsoleLog);
|
||||||
|
|||||||
@@ -38,14 +38,15 @@ export function getLocalLibsqlConnection() {
|
|||||||
return { url: "http://127.0.0.1:8080" };
|
return { url: "http://127.0.0.1:8080" };
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConsoleSeverity = "log" | "warn" | "error";
|
type ConsoleSeverity = "debug" | "log" | "warn" | "error";
|
||||||
const _oldConsoles = {
|
const _oldConsoles = {
|
||||||
|
debug: console.debug,
|
||||||
log: console.log,
|
log: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
error: console.error,
|
error: console.error,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function disableConsoleLog(severities: ConsoleSeverity[] = ["log", "warn"]) {
|
export function disableConsoleLog(severities: ConsoleSeverity[] = ["debug", "log", "warn"]) {
|
||||||
severities.forEach((severity) => {
|
severities.forEach((severity) => {
|
||||||
console[severity] = () => null;
|
console[severity] = () => null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import { createApp } from "../../src";
|
import { createApp } from "core/test/utils";
|
||||||
import { Api } from "../../src/Api";
|
import { Api } from "../../src/Api";
|
||||||
|
|
||||||
describe("integration config", () => {
|
describe("integration config", () => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/// <reference types="@types/bun" />
|
/// <reference types="@types/bun" />
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { createApp, registries } from "../../src";
|
import { registries } from "../../src";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
import { mergeObject, randomString } from "../../src/core/utils";
|
import { mergeObject, randomString } from "../../src/core/utils";
|
||||||
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
import type { TAppMediaConfig } from "../../src/media/media-schema";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
@@ -43,8 +44,9 @@ beforeAll(disableConsoleLog);
|
|||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("MediaController", () => {
|
describe("MediaController", () => {
|
||||||
test.only("accepts direct", async () => {
|
test("accepts direct", async () => {
|
||||||
const app = await makeApp();
|
const app = await makeApp();
|
||||||
|
console.log("app", app);
|
||||||
|
|
||||||
const file = Bun.file(path);
|
const file = Bun.file(path);
|
||||||
const name = makeName("png");
|
const name = makeName("png");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||||
import { createApp } from "../../src";
|
import { createApp } from "core/test/utils";
|
||||||
import { AuthController } from "../../src/auth/api/AuthController";
|
import { AuthController } from "../../src/auth/api/AuthController";
|
||||||
import { em, entity, make, text } from "../../src/data";
|
import { em, entity, make, text } from "../../src/data";
|
||||||
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
import { AppAuth, type ModuleBuildContext } from "../../src/modules";
|
||||||
@@ -69,7 +69,7 @@ describe("AppAuth", () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: "some@body.com",
|
email: "some@body.com",
|
||||||
password: "123456",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
enableConsoleLog();
|
enableConsoleLog();
|
||||||
@@ -81,6 +81,65 @@ describe("AppAuth", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("creates user on register (bcrypt)", async () => {
|
||||||
|
const auth = new AppAuth(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
strategies: {
|
||||||
|
password: {
|
||||||
|
type: "password",
|
||||||
|
config: {
|
||||||
|
hashing: "bcrypt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
jwt: {
|
||||||
|
secret: "123456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await auth.build();
|
||||||
|
await ctx.em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
// expect no users, but the query to pass
|
||||||
|
const res = await ctx.em.repository("users").findMany();
|
||||||
|
expect(res.data.length).toBe(0);
|
||||||
|
|
||||||
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
|
{
|
||||||
|
disableConsoleLog();
|
||||||
|
const res = await app.request("/password/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "some@body.com",
|
||||||
|
password: "12345678",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
enableConsoleLog();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
|
expect(users.length).toBe(1);
|
||||||
|
expect(users[0]?.email).toBe("some@body.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// check user in database
|
||||||
|
const rawUser = await ctx.connection.kysely
|
||||||
|
.selectFrom("users")
|
||||||
|
.selectAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(rawUser.strategy_value).toStartWith("$");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("registers auth middleware for bknd routes only", async () => {
|
test("registers auth middleware for bknd routes only", async () => {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
initialConfig: {
|
initialConfig: {
|
||||||
@@ -94,6 +153,7 @@ describe("AppAuth", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await app.build();
|
await app.build();
|
||||||
|
app.registerAdminController();
|
||||||
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
const spy = spyOn(app.module.auth.authenticator, "requestCookieRefresh");
|
||||||
|
|
||||||
// register custom route
|
// register custom route
|
||||||
@@ -103,6 +163,10 @@ describe("AppAuth", () => {
|
|||||||
await app.server.request("/api/system/ping");
|
await app.server.request("/api/system/ping");
|
||||||
await app.server.request("/test");
|
await app.server.request("/test");
|
||||||
|
|
||||||
|
expect(spy.mock.calls.length).toBe(0);
|
||||||
|
|
||||||
|
// admin route
|
||||||
|
await app.server.request("/");
|
||||||
expect(spy.mock.calls.length).toBe(1);
|
expect(spy.mock.calls.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createApp, registries } from "../../src";
|
import { registries } from "../../src";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
import { em, entity, text } from "../../src/data";
|
import { em, entity, text } from "../../src/data";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/modules";
|
import { AppMedia } from "../../src/modules";
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { type TSchema, Type, stripMark } from "../../src/core/utils";
|
import { stripMark } from "../../src/core/utils";
|
||||||
|
import { type TSchema, Type } from "@sinclair/typebox";
|
||||||
import { EntityManager, em, entity, index, text } from "../../src/data";
|
import { EntityManager, em, entity, index, text } from "../../src/data";
|
||||||
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
import { DummyConnection } from "../../src/data/connection/DummyConnection";
|
||||||
import { Module } from "../../src/modules/Module";
|
import { Module } from "../../src/modules/Module";
|
||||||
|
import { ModuleHelper } from "modules/ModuleHelper";
|
||||||
|
|
||||||
function createModule<Schema extends TSchema>(schema: Schema) {
|
function createModule<Schema extends TSchema>(schema: Schema) {
|
||||||
class TestModule extends Module<typeof schema> {
|
class TestModule extends Module<typeof schema> {
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
toJSON() {
|
override toJSON() {
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
useForceParse() {
|
override useForceParse() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,9 +47,9 @@ describe("Module", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prt = {
|
prt = {
|
||||||
ensureEntity: this.ensureEntity.bind(this),
|
ensureEntity: this.ctx.helper.ensureEntity.bind(this.ctx.helper),
|
||||||
ensureIndex: this.ensureIndex.bind(this),
|
ensureIndex: this.ctx.helper.ensureIndex.bind(this.ctx.helper),
|
||||||
ensureSchema: this.ensureSchema.bind(this),
|
ensureSchema: this.ctx.helper.ensureSchema.bind(this.ctx.helper),
|
||||||
};
|
};
|
||||||
|
|
||||||
get em() {
|
get em() {
|
||||||
@@ -62,7 +64,11 @@ describe("Module", async () => {
|
|||||||
_em.relations,
|
_em.relations,
|
||||||
_em.indices,
|
_em.indices,
|
||||||
);
|
);
|
||||||
return new M({} as any, { em, flags: Module.ctx_flags } as any);
|
const ctx = {
|
||||||
|
em,
|
||||||
|
flags: Module.ctx_flags,
|
||||||
|
};
|
||||||
|
return new M({} as any, { ...ctx, helper: new ModuleHelper(ctx as any) } as any);
|
||||||
}
|
}
|
||||||
function flat(_em: EntityManager) {
|
function flat(_em: EntityManager) {
|
||||||
return {
|
return {
|
||||||
@@ -142,14 +148,9 @@ describe("Module", async () => {
|
|||||||
|
|
||||||
// this should only add the field "important"
|
// this should only add the field "important"
|
||||||
m.prt.ensureEntity(
|
m.prt.ensureEntity(
|
||||||
entity(
|
entity("u", {
|
||||||
"u",
|
|
||||||
{
|
|
||||||
important: text(),
|
important: text(),
|
||||||
},
|
}),
|
||||||
undefined,
|
|
||||||
"system",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(m.ctx.flags.sync_required).toBe(true);
|
expect(m.ctx.flags.sync_required).toBe(true);
|
||||||
@@ -158,8 +159,7 @@ describe("Module", async () => {
|
|||||||
{
|
{
|
||||||
name: "u",
|
name: "u",
|
||||||
fields: ["id", "name", "important"],
|
fields: ["id", "name", "important"],
|
||||||
// ensured type must be present
|
type: "regular",
|
||||||
type: "system",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "p",
|
name: "p",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { disableConsoleLog, enableConsoleLog, stripMark, Type } from "../../src/core/utils";
|
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
|
||||||
import { Connection, entity, text } from "../../src/data";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { Module } from "../../src/modules/Module";
|
import { Connection, entity, text } from "data";
|
||||||
import { type ConfigTable, getDefaultConfig, ModuleManager } from "../../src/modules/ModuleManager";
|
import { Module } from "modules/Module";
|
||||||
import { CURRENT_VERSION, TABLE_NAME } from "../../src/modules/migrations";
|
import { type ConfigTable, getDefaultConfig, ModuleManager } from "modules/ModuleManager";
|
||||||
|
import { CURRENT_VERSION, TABLE_NAME } from "modules/migrations";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
import { diff } from "core/object/diff";
|
import { diff } from "core/object/diff";
|
||||||
import type { Static } from "@sinclair/typebox";
|
import type { Static } from "@sinclair/typebox";
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import { Default, stripMark } from "../../src/core/utils";
|
|||||||
import { EntityManager } from "../../src/data";
|
import { EntityManager } from "../../src/data";
|
||||||
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
|
import { Module, type ModuleBuildContext } from "../../src/modules/Module";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
import { ModuleHelper } from "modules/ModuleHelper";
|
||||||
|
|
||||||
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildContext {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
return {
|
const ctx = {
|
||||||
connection: dummyConnection,
|
connection: dummyConnection,
|
||||||
server: new Hono(),
|
server: new Hono(),
|
||||||
em: new EntityManager([], dummyConnection),
|
em: new EntityManager([], dummyConnection),
|
||||||
@@ -21,6 +22,10 @@ export function makeCtx(overrides?: Partial<ModuleBuildContext>): ModuleBuildCon
|
|||||||
logger: new DebugLogger(false),
|
logger: new DebugLogger(false),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
...ctx,
|
||||||
|
helper: new ModuleHelper(ctx as any),
|
||||||
|
} as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moduleTestSuite(module: { new (): Module }) {
|
export function moduleTestSuite(module: { new (): Module }) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import pkg from "./package.json" with { type: "json" };
|
||||||
|
import c from "picocolors";
|
||||||
|
import { formatNumber } from "core/utils";
|
||||||
|
|
||||||
|
const result = await Bun.build({
|
||||||
|
entrypoints: ["./src/cli/index.ts"],
|
||||||
|
target: "node",
|
||||||
|
outdir: "./dist/cli",
|
||||||
|
env: "PUBLIC_*",
|
||||||
|
minify: true,
|
||||||
|
define: {
|
||||||
|
__isDev: "0",
|
||||||
|
__version: JSON.stringify(pkg.version),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const output of result.outputs) {
|
||||||
|
const size_ = await output.text();
|
||||||
|
console.info(
|
||||||
|
c.cyan(formatNumber.fileSize(size_.length)),
|
||||||
|
c.dim(output.path.replace(import.meta.dir + "/", "")),
|
||||||
|
);
|
||||||
|
}
|
||||||
+56
-15
@@ -1,5 +1,6 @@
|
|||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
|
import pkg from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const watch = args.includes("--watch");
|
const watch = args.includes("--watch");
|
||||||
@@ -8,8 +9,13 @@ const types = args.includes("--types");
|
|||||||
const sourcemap = args.includes("--sourcemap");
|
const sourcemap = args.includes("--sourcemap");
|
||||||
const clean = args.includes("--clean");
|
const clean = args.includes("--clean");
|
||||||
|
|
||||||
|
const define = {
|
||||||
|
__isDev: "0",
|
||||||
|
__version: JSON.stringify(pkg.version),
|
||||||
|
};
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
console.log("Cleaning dist (w/o static)");
|
console.info("Cleaning dist (w/o static)");
|
||||||
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
await $`find dist -mindepth 1 ! -path "dist/static/*" ! -path "dist/static" -exec rm -rf {} +`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,11 +27,11 @@ function buildTypes() {
|
|||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types built");
|
console.info("Types built");
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
console.log("Types aliased");
|
console.info("Types aliased");
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -47,14 +53,21 @@ if (types && !watch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function banner(title: string) {
|
function banner(title: string) {
|
||||||
console.log("");
|
console.info("");
|
||||||
console.log("=".repeat(40));
|
console.info("=".repeat(40));
|
||||||
console.log(title.toUpperCase());
|
console.info(title.toUpperCase());
|
||||||
console.log("-".repeat(40));
|
console.info("-".repeat(40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
|
const external = [
|
||||||
|
"bun:test",
|
||||||
|
"node:test",
|
||||||
|
"node:assert/strict",
|
||||||
|
"@libsql/client",
|
||||||
|
"bknd",
|
||||||
|
/^bknd\/.*/,
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
@@ -65,12 +78,14 @@ async function buildApi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
|
define,
|
||||||
entry: [
|
entry: [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/core/index.ts",
|
"src/core/index.ts",
|
||||||
"src/core/utils/index.ts",
|
"src/core/utils/index.ts",
|
||||||
"src/data/index.ts",
|
"src/data/index.ts",
|
||||||
"src/media/index.ts",
|
"src/media/index.ts",
|
||||||
|
"src/plugins/index.ts",
|
||||||
],
|
],
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
external: [...external],
|
external: [...external],
|
||||||
@@ -101,6 +116,7 @@ async function buildUi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
@@ -160,6 +176,7 @@ async function buildUiElements() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
|
define,
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
@@ -211,14 +228,15 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
|||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
...define,
|
||||||
...overrides.define,
|
...overrides.define,
|
||||||
},
|
},
|
||||||
external: [
|
external: [
|
||||||
/^cloudflare*/,
|
/^cloudflare*/,
|
||||||
/^@?(hono|libsql).*?/,
|
/^@?(hono).*?/,
|
||||||
/^(bknd|react|next|node).*?/,
|
/^(bknd|react|next|node).*?/,
|
||||||
/.*\.(html)$/,
|
/.*\.(html)$/,
|
||||||
|
...external,
|
||||||
...(Array.isArray(overrides.external) ? overrides.external : []),
|
...(Array.isArray(overrides.external) ? overrides.external : []),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -235,14 +253,14 @@ async function buildAdapters() {
|
|||||||
|
|
||||||
// specific adatpers
|
// specific adatpers
|
||||||
await tsup.build(baseConfig("react-router"));
|
await tsup.build(baseConfig("react-router"));
|
||||||
await tsup.build(baseConfig("bun"));
|
|
||||||
await tsup.build(baseConfig("astro"));
|
|
||||||
await tsup.build(baseConfig("aws"));
|
|
||||||
await tsup.build(
|
await tsup.build(
|
||||||
baseConfig("cloudflare", {
|
baseConfig("bun", {
|
||||||
external: [/^kysely/],
|
external: [/^bun\:.*/],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
await tsup.build(baseConfig("astro"));
|
||||||
|
await tsup.build(baseConfig("aws"));
|
||||||
|
await tsup.build(baseConfig("cloudflare"));
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("vite"),
|
||||||
@@ -258,6 +276,29 @@ async function buildAdapters() {
|
|||||||
...baseConfig("node"),
|
...baseConfig("node"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("sqlite/edge"),
|
||||||
|
entry: ["src/adapter/sqlite/edge.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("sqlite/node"),
|
||||||
|
entry: ["src/adapter/sqlite/node.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
platform: "node",
|
||||||
|
metafile: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await tsup.build({
|
||||||
|
...baseConfig("sqlite/bun"),
|
||||||
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
external: [/^bun\:.*/],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await buildApi();
|
await buildApi();
|
||||||
|
|||||||
+5
-1
@@ -142,6 +142,7 @@ const adapters = {
|
|||||||
},
|
},
|
||||||
nextjs: {
|
nextjs: {
|
||||||
dir: path.join(basePath, "examples/nextjs"),
|
dir: path.join(basePath, "examples/nextjs"),
|
||||||
|
env: "TEST_TIMEOUT=20000",
|
||||||
clean: async function () {
|
clean: async function () {
|
||||||
const cwd = path.relative(process.cwd(), this.dir);
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
await $`cd ${cwd} && rm -rf .nextjs data.db`;
|
await $`cd ${cwd} && rm -rf .nextjs data.db`;
|
||||||
@@ -195,7 +196,8 @@ async function testAdapter(name: keyof typeof adapters) {
|
|||||||
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
||||||
//proc.kill();process.exit(0);
|
//proc.kill();process.exit(0);
|
||||||
|
|
||||||
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
|
const add_env = "env" in config && config.env ? config.env : "";
|
||||||
|
await $`TEST_URL=${data} TEST_ADAPTER=${name} ${add_env} bun run test:e2e`;
|
||||||
console.log("DONE!");
|
console.log("DONE!");
|
||||||
|
|
||||||
while (!proc.killed) {
|
while (!proc.killed) {
|
||||||
@@ -205,6 +207,8 @@ async function testAdapter(name: keyof typeof adapters) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// run with: TEST_ADAPTER=astro bun run e2e/adapters.ts
|
||||||
|
// (modify `test:e2e` to `test:e2e:ui` to see the UI)
|
||||||
if (process.env.TEST_ADAPTER) {
|
if (process.env.TEST_ADAPTER) {
|
||||||
await testAdapter(process.env.TEST_ADAPTER as any);
|
await testAdapter(process.env.TEST_ADAPTER as any);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+61
-19
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.11.2",
|
"version": "0.15.0-rc.10",
|
||||||
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
"description": "Lightweight Firebase/Supabase alternative built to run anywhere — incl. Next.js, React Router, Astro, Cloudflare, Bun, Node, AWS Lambda & more.",
|
||||||
"homepage": "https://bknd.io",
|
"homepage": "https://bknd.io",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -13,12 +13,15 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/bknd-io/bknd/issues"
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||||
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
"build": "NODE_ENV=production bun run build.ts --minify --types",
|
||||||
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
"build:all": "rm -rf dist && bun run build:static && NODE_ENV=production bun run build.ts --minify --types --clean && bun run build:cli",
|
||||||
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
|
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
|
||||||
"build:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
|
"build:cli": "bun run build.cli.ts",
|
||||||
"build:static": "vite build",
|
"build:static": "vite build",
|
||||||
"watch": "bun run build.ts --types --watch",
|
"watch": "bun run build.ts --types --watch",
|
||||||
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
"types": "bun tsc -p tsconfig.build.json --noEmit",
|
||||||
@@ -31,11 +34,9 @@
|
|||||||
"test": "ALL_TESTS=1 bun test --bail",
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
"test:all": "bun run test && bun run test:node",
|
"test:all": "bun run test && bun run test:node",
|
||||||
"test:bun": "ALL_TESTS=1 bun test --bail",
|
"test:bun": "ALL_TESTS=1 bun test --bail",
|
||||||
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
|
"test:node": "vitest run",
|
||||||
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
||||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||||
"test:vitest": "vitest run",
|
|
||||||
"test:vitest:watch": "vitest",
|
|
||||||
"test:vitest:coverage": "vitest run --coverage",
|
"test:vitest:coverage": "vitest run --coverage",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||||
@@ -48,40 +49,40 @@
|
|||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-liquid": "^6.2.2",
|
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@libsql/client": "^0.15.2",
|
"@hono/swagger-ui": "^0.5.1",
|
||||||
"@mantine/core": "^7.17.1",
|
"@mantine/core": "^7.17.1",
|
||||||
"@mantine/hooks": "^7.17.1",
|
"@mantine/hooks": "^7.17.1",
|
||||||
"@sinclair/typebox": "^0.34.30",
|
"@sinclair/typebox": "0.34.30",
|
||||||
"@tanstack/react-form": "^1.0.5",
|
"@tanstack/react-form": "^1.0.5",
|
||||||
"@uiw/react-codemirror": "^4.23.10",
|
"@uiw/react-codemirror": "^4.23.10",
|
||||||
"@xyflow/react": "^12.4.4",
|
"@xyflow/react": "^12.4.4",
|
||||||
"aws4fetch": "^1.0.20",
|
"aws4fetch": "^1.0.20",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"fast-xml-parser": "^5.0.8",
|
"fast-xml-parser": "^5.0.8",
|
||||||
"hono": "^4.7.4",
|
"hono": "^4.7.11",
|
||||||
"json-schema-form-react": "^0.0.2",
|
"json-schema-form-react": "^0.0.2",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
|
"jsonv-ts": "^0.1.0",
|
||||||
"kysely": "^0.27.6",
|
"kysely": "^0.27.6",
|
||||||
"liquidjs": "^10.21.0",
|
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
"object-path-immutable": "^4.1.2",
|
"object-path-immutable": "^4.1.2",
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"radix-ui": "^1.1.3",
|
"radix-ui": "^1.1.3",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3"
|
||||||
"wrangler": "^4.4.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.758.0",
|
"@aws-sdk/client-s3": "^3.758.0",
|
||||||
"@bluwy/giget-core": "^0.1.2",
|
"@bluwy/giget-core": "^0.1.2",
|
||||||
|
"@cloudflare/vitest-pool-workers": "^0.8.38",
|
||||||
|
"@cloudflare/workers-types": "^4.20250606.0",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
"@hono/typebox-validator": "^0.3.2",
|
"@hono/typebox-validator": "^0.3.3",
|
||||||
"@hono/vite-dev-server": "^0.19.0",
|
"@hono/vite-dev-server": "^0.19.1",
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/client": "^0.15.9",
|
||||||
"@mantine/modals": "^7.17.1",
|
"@mantine/modals": "^7.17.1",
|
||||||
"@mantine/notifications": "^7.17.1",
|
"@mantine/notifications": "^7.17.1",
|
||||||
"@playwright/test": "^1.51.1",
|
"@playwright/test": "^1.51.1",
|
||||||
@@ -102,8 +103,11 @@
|
|||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.0",
|
||||||
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
"open": "^10.1.0",
|
"open": "^10.1.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"postcss-preset-mantine": "^1.17.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
@@ -120,13 +124,14 @@
|
|||||||
"tsc-alias": "^1.8.11",
|
"tsc-alias": "^1.8.11",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"vite": "^6.2.1",
|
"uuid": "^11.1.0",
|
||||||
|
"vite": "^6.3.5",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "^3.0.9",
|
"vitest": "^3.0.9",
|
||||||
"wouter": "^3.6.0"
|
"wouter": "^3.6.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.13.8"
|
"@hono/node-server": "^1.14.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=19",
|
"react": ">=19",
|
||||||
@@ -181,6 +186,25 @@
|
|||||||
"import": "./dist/media/index.js",
|
"import": "./dist/media/index.js",
|
||||||
"require": "./dist/media/index.js"
|
"require": "./dist/media/index.js"
|
||||||
},
|
},
|
||||||
|
"./plugins": {
|
||||||
|
"types": "./dist/types/plugins/index.d.ts",
|
||||||
|
"import": "./dist/plugins/index.js",
|
||||||
|
"require": "./dist/plugins/index.js"
|
||||||
|
},
|
||||||
|
"./adapter/sqlite": {
|
||||||
|
"types": "./dist/types/adapter/sqlite/edge.d.ts",
|
||||||
|
"import": {
|
||||||
|
"workerd": "./dist/adapter/sqlite/edge.js",
|
||||||
|
"edge-light": "./dist/adapter/sqlite/edge.js",
|
||||||
|
"netlify": "./dist/adapter/sqlite/edge.js",
|
||||||
|
"vercel": "./dist/adapter/sqlite/edge.js",
|
||||||
|
"browser": "./dist/adapter/sqlite/edge.js",
|
||||||
|
"bun": "./dist/adapter/sqlite/bun.js",
|
||||||
|
"node": "./dist/adapter/sqlite/node.js",
|
||||||
|
"default": "./dist/adapter/sqlite/node.js"
|
||||||
|
},
|
||||||
|
"require": "./dist/adapter/sqlite/node.js"
|
||||||
|
},
|
||||||
"./adapter/cloudflare": {
|
"./adapter/cloudflare": {
|
||||||
"types": "./dist/types/adapter/cloudflare/index.d.ts",
|
"types": "./dist/types/adapter/cloudflare/index.d.ts",
|
||||||
"import": "./dist/adapter/cloudflare/index.js",
|
"import": "./dist/adapter/cloudflare/index.js",
|
||||||
@@ -229,6 +253,24 @@
|
|||||||
"./dist/styles.css": "./dist/ui/styles.css",
|
"./dist/styles.css": "./dist/ui/styles.css",
|
||||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json"
|
||||||
},
|
},
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"data": ["./dist/types/data/index.d.ts"],
|
||||||
|
"core": ["./dist/types/core/index.d.ts"],
|
||||||
|
"utils": ["./dist/types/core/utils/index.d.ts"],
|
||||||
|
"cli": ["./dist/types/cli/index.d.ts"],
|
||||||
|
"media": ["./dist/types/media/index.d.ts"],
|
||||||
|
"plugins": ["./dist/types/plugins/index.d.ts"],
|
||||||
|
"adapter": ["./dist/types/adapter/index.d.ts"],
|
||||||
|
"adapter/cloudflare": ["./dist/types/adapter/cloudflare/index.d.ts"],
|
||||||
|
"adapter/vite": ["./dist/types/adapter/vite/index.d.ts"],
|
||||||
|
"adapter/nextjs": ["./dist/types/adapter/nextjs/index.d.ts"],
|
||||||
|
"adapter/react-router": ["./dist/types/adapter/react-router/index.d.ts"],
|
||||||
|
"adapter/bun": ["./dist/types/adapter/bun/index.d.ts"],
|
||||||
|
"adapter/node": ["./dist/types/adapter/node/index.d.ts"],
|
||||||
|
"adapter/sqlite": ["./dist/types/adapter/sqlite/edge.d.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|||||||
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
|
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
|
||||||
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
|
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
|
||||||
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
|
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
|
||||||
|
const timeout = process.env.TEST_TIMEOUT ? Number.parseInt(process.env.TEST_TIMEOUT) : 5000;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testMatch: "**/*.e2e-spec.ts",
|
testMatch: "**/*.e2e-spec.ts",
|
||||||
@@ -12,7 +13,7 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 20000,
|
timeout,
|
||||||
use: {
|
use: {
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
trace: "on-first-retry",
|
trace: "on-first-retry",
|
||||||
|
|||||||
+76
-23
@@ -1,13 +1,19 @@
|
|||||||
import type { SafeUser } from "auth";
|
import type { SafeUser } from "auth";
|
||||||
import { AuthApi } from "auth/api/AuthApi";
|
import { AuthApi, type AuthApiOptions } from "auth/api/AuthApi";
|
||||||
import { DataApi } from "data/api/DataApi";
|
import { DataApi, type DataApiOptions } from "data/api/DataApi";
|
||||||
import { decode } from "hono/jwt";
|
import { decode } from "hono/jwt";
|
||||||
import { MediaApi } from "media/api/MediaApi";
|
import { MediaApi, type MediaApiOptions } from "media/api/MediaApi";
|
||||||
import { SystemApi } from "modules/SystemApi";
|
import { SystemApi } from "modules/SystemApi";
|
||||||
import { omitKeys } from "core/utils";
|
import { omitKeys } from "core/utils";
|
||||||
|
import type { BaseModuleApiOptions } from "modules";
|
||||||
|
|
||||||
export type TApiUser = SafeUser;
|
export type TApiUser = SafeUser;
|
||||||
|
|
||||||
|
export type ApiFetcher = (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__BKND__: {
|
__BKND__: {
|
||||||
@@ -16,14 +22,24 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SubApiOptions<T extends BaseModuleApiOptions> = Omit<T, keyof BaseModuleApiOptions>;
|
||||||
|
|
||||||
export type ApiOptions = {
|
export type ApiOptions = {
|
||||||
host?: string;
|
host?: string;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
key?: string;
|
key?: string;
|
||||||
localStorage?: boolean;
|
storage?: {
|
||||||
fetcher?: typeof fetch;
|
getItem: (key: string) => string | undefined | null | Promise<string | undefined | null>;
|
||||||
|
setItem: (key: string, value: string) => void | Promise<void>;
|
||||||
|
removeItem: (key: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
onAuthStateChange?: (state: AuthState) => void;
|
||||||
|
fetcher?: ApiFetcher;
|
||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
verified?: boolean;
|
verified?: boolean;
|
||||||
|
data?: SubApiOptions<DataApiOptions>;
|
||||||
|
auth?: SubApiOptions<AuthApiOptions>;
|
||||||
|
media?: SubApiOptions<MediaApiOptions>;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -56,18 +72,18 @@ export class Api {
|
|||||||
this.verified = options.verified === true;
|
this.verified = options.verified === true;
|
||||||
|
|
||||||
// prefer request if given
|
// prefer request if given
|
||||||
if ("request" in options) {
|
if ("request" in options && options.request) {
|
||||||
this.options.host = options.host ?? new URL(options.request.url).origin;
|
this.options.host = options.host ?? new URL(options.request.url).origin;
|
||||||
this.options.headers = options.headers ?? options.request.headers;
|
this.options.headers = options.headers ?? options.request.headers;
|
||||||
this.extractToken();
|
this.extractToken();
|
||||||
|
|
||||||
// then check for a token
|
// then check for a token
|
||||||
} else if ("token" in options) {
|
} else if ("token" in options && options.token) {
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(options.token);
|
this.updateToken(options.token, { trigger: false });
|
||||||
|
|
||||||
// then check for an user object
|
// then check for an user object
|
||||||
} else if ("user" in options) {
|
} else if ("user" in options && options.user) {
|
||||||
this.token_transport = "none";
|
this.token_transport = "none";
|
||||||
this.user = options.user;
|
this.user = options.user;
|
||||||
this.verified = options.verified !== false;
|
this.verified = options.verified !== false;
|
||||||
@@ -110,18 +126,30 @@ export class Api {
|
|||||||
this.updateToken(headerToken);
|
this.updateToken(headerToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (this.options.localStorage) {
|
} else if (this.storage) {
|
||||||
const token = localStorage.getItem(this.tokenKey);
|
this.storage.getItem(this.tokenKey).then((token) => {
|
||||||
if (token) {
|
|
||||||
this.token_transport = "header";
|
this.token_transport = "header";
|
||||||
this.updateToken(token);
|
this.updateToken(token ? String(token) : undefined);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.warn("Couldn't extract token");
|
private get storage() {
|
||||||
|
if (!this.options.storage) return null;
|
||||||
|
return {
|
||||||
|
getItem: async (key: string) => {
|
||||||
|
return await this.options.storage!.getItem(key);
|
||||||
|
},
|
||||||
|
setItem: async (key: string, value: string) => {
|
||||||
|
return await this.options.storage!.setItem(key, value);
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => {
|
||||||
|
return await this.options.storage!.removeItem(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
updateToken(token?: string, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.verified = false;
|
this.verified = false;
|
||||||
|
|
||||||
@@ -131,17 +159,25 @@ export class Api {
|
|||||||
this.user = undefined;
|
this.user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.options.localStorage) {
|
if (this.storage) {
|
||||||
const key = this.tokenKey;
|
const key = this.tokenKey;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem(key, token);
|
this.storage.setItem(key, token).then(() => {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(key);
|
this.storage.removeItem(key).then(() => {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (opts?.trigger !== false) {
|
||||||
|
this.options.onAuthStateChange?.(this.getAuthState());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rebuild) this.buildApis();
|
if (opts?.rebuild) this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
private markAuthVerified(verfied: boolean) {
|
private markAuthVerified(verfied: boolean) {
|
||||||
@@ -211,15 +247,32 @@ export class Api {
|
|||||||
const fetcher = this.options.fetcher;
|
const fetcher = this.options.fetcher;
|
||||||
|
|
||||||
this.system = new SystemApi(baseParams, fetcher);
|
this.system = new SystemApi(baseParams, fetcher);
|
||||||
this.data = new DataApi(baseParams, fetcher);
|
this.data = new DataApi(
|
||||||
this.auth = new AuthApi(
|
|
||||||
{
|
{
|
||||||
...baseParams,
|
...baseParams,
|
||||||
onTokenUpdate: (token) => this.updateToken(token, true),
|
...this.options.data,
|
||||||
|
},
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
this.auth = new AuthApi(
|
||||||
|
{
|
||||||
|
...baseParams,
|
||||||
|
credentials: this.options.storage ? "omit" : "include",
|
||||||
|
...this.options.auth,
|
||||||
|
onTokenUpdate: (token) => {
|
||||||
|
this.updateToken(token, { rebuild: true });
|
||||||
|
this.options.auth?.onTokenUpdate?.(token);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fetcher,
|
||||||
|
);
|
||||||
|
this.media = new MediaApi(
|
||||||
|
{
|
||||||
|
...baseParams,
|
||||||
|
...this.options.media,
|
||||||
},
|
},
|
||||||
fetcher,
|
fetcher,
|
||||||
);
|
);
|
||||||
this.media = new MediaApi(baseParams, fetcher);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+102
-53
@@ -1,7 +1,8 @@
|
|||||||
import type { CreateUserPayload } from "auth/AppAuth";
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
import { $console } from "core";
|
import { $console } from "core/utils";
|
||||||
import { Event } from "core/events";
|
import { Event } from "core/events";
|
||||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
import type { em as prototypeEm } from "data/prototype";
|
||||||
|
import { Connection } from "data/connection/Connection";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
ModuleManager,
|
ModuleManager,
|
||||||
@@ -14,15 +15,29 @@ import {
|
|||||||
import * as SystemPermissions from "modules/permissions";
|
import * as SystemPermissions from "modules/permissions";
|
||||||
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
import { AdminController, type AdminControllerOptions } from "modules/server/AdminController";
|
||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
|
import type { MaybePromise } from "core/types";
|
||||||
// biome-ignore format: must be there
|
|
||||||
import { Api, type ApiOptions } from "Api";
|
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
|
import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
||||||
|
|
||||||
export type AppPlugin = (app: App) => Promise<void> | void;
|
// biome-ignore format: must be here
|
||||||
|
import { Api, type ApiOptions } from "Api";
|
||||||
|
|
||||||
|
export type AppPluginConfig = {
|
||||||
|
name: string;
|
||||||
|
schema?: () => MaybePromise<ReturnType<typeof prototypeEm> | void>;
|
||||||
|
beforeBuild?: () => MaybePromise<void>;
|
||||||
|
onBuilt?: () => MaybePromise<void>;
|
||||||
|
onServerInit?: (server: Hono<ServerEnv>) => MaybePromise<void>;
|
||||||
|
onFirstBoot?: () => MaybePromise<void>;
|
||||||
|
onBoot?: () => MaybePromise<void>;
|
||||||
|
};
|
||||||
|
export type AppPlugin = (app: App) => AppPluginConfig;
|
||||||
|
|
||||||
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
|
abstract class AppEvent<A = {}> extends Event<{ app: App } & A> {}
|
||||||
export class AppConfigUpdatedEvent extends AppEvent {
|
export class AppConfigUpdatedEvent extends AppEvent<{
|
||||||
|
module: string;
|
||||||
|
config: ModuleConfigs[keyof ModuleConfigs];
|
||||||
|
}> {
|
||||||
static override slug = "app-config-updated";
|
static override slug = "app-config-updated";
|
||||||
}
|
}
|
||||||
export class AppBuiltEvent extends AppEvent {
|
export class AppBuiltEvent extends AppEvent {
|
||||||
@@ -50,16 +65,13 @@ export type AppOptions = {
|
|||||||
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
|
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
|
||||||
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
||||||
asyncEventsMode?: "sync" | "async" | "none";
|
asyncEventsMode?: "sync" | "async" | "none";
|
||||||
|
drivers?: {
|
||||||
|
email?: IEmailDriver;
|
||||||
|
cache?: ICacheDriver;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
connection?:
|
connection?: Connection | { url: string };
|
||||||
| Connection
|
|
||||||
| {
|
|
||||||
// @deprecated
|
|
||||||
type: "libsql";
|
|
||||||
config: LibSqlCredentials;
|
|
||||||
}
|
|
||||||
| LibSqlCredentials;
|
|
||||||
initialConfig?: InitialModuleConfigs;
|
initialConfig?: InitialModuleConfigs;
|
||||||
options?: AppOptions;
|
options?: AppOptions;
|
||||||
};
|
};
|
||||||
@@ -67,29 +79,40 @@ export type CreateAppConfig = {
|
|||||||
export type AppConfig = InitialModuleConfigs;
|
export type AppConfig = InitialModuleConfigs;
|
||||||
export type LocalApiOptions = Request | ApiOptions;
|
export type LocalApiOptions = Request | ApiOptions;
|
||||||
|
|
||||||
export class App {
|
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
|
||||||
static readonly Events = AppEvents;
|
static readonly Events = AppEvents;
|
||||||
|
|
||||||
modules: ModuleManager;
|
modules: ModuleManager;
|
||||||
adminController?: AdminController;
|
adminController?: AdminController;
|
||||||
_id: string = crypto.randomUUID();
|
_id: string = crypto.randomUUID();
|
||||||
|
plugins: Map<string, AppPluginConfig> = new Map();
|
||||||
|
drivers: Options["drivers"] = {};
|
||||||
|
|
||||||
private trigger_first_boot = false;
|
private trigger_first_boot = false;
|
||||||
private plugins: AppPlugin[];
|
|
||||||
private _building: boolean = false;
|
private _building: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private connection: Connection,
|
public connection: C,
|
||||||
_initialConfig?: InitialModuleConfigs,
|
_initialConfig?: InitialModuleConfigs,
|
||||||
private options?: AppOptions,
|
private options?: Options,
|
||||||
) {
|
) {
|
||||||
this.plugins = options?.plugins ?? [];
|
this.drivers = options?.drivers ?? {};
|
||||||
|
|
||||||
|
for (const plugin of options?.plugins ?? []) {
|
||||||
|
const config = plugin(this);
|
||||||
|
if (this.plugins.has(config.name)) {
|
||||||
|
throw new Error(`Plugin ${config.name} already registered`);
|
||||||
|
}
|
||||||
|
this.plugins.set(config.name, config);
|
||||||
|
}
|
||||||
|
this.runPlugins("onBoot");
|
||||||
this.modules = new ModuleManager(connection, {
|
this.modules = new ModuleManager(connection, {
|
||||||
...(options?.manager ?? {}),
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _initialConfig,
|
||||||
onUpdated: this.onUpdated.bind(this),
|
onUpdated: this.onUpdated.bind(this),
|
||||||
onFirstBoot: this.onFirstBoot.bind(this),
|
onFirstBoot: this.onFirstBoot.bind(this),
|
||||||
onServerInit: this.onServerInit.bind(this),
|
onServerInit: this.onServerInit.bind(this),
|
||||||
|
onModulesBuilt: this.onModulesBuilt.bind(this),
|
||||||
});
|
});
|
||||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
}
|
}
|
||||||
@@ -98,6 +121,32 @@ export class App {
|
|||||||
return this.modules.ctx().emgr;
|
return this.modules.ctx().emgr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async runPlugins<Key extends keyof AppPluginConfig>(
|
||||||
|
key: Key,
|
||||||
|
...args: any[]
|
||||||
|
): Promise<{ name: string; result: any }[]> {
|
||||||
|
const results: { name: string; result: any }[] = [];
|
||||||
|
for (const [name, config] of this.plugins) {
|
||||||
|
try {
|
||||||
|
if (key in config && config[key]) {
|
||||||
|
const fn = config[key];
|
||||||
|
if (fn && typeof fn === "function") {
|
||||||
|
$console.debug(`[Plugin:${name}] ${key}`);
|
||||||
|
// @ts-expect-error
|
||||||
|
const result = await fn(...args);
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
$console.warn(`[Plugin:${name}] error running "${key}"`, String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results as any;
|
||||||
|
}
|
||||||
|
|
||||||
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
|
async build(options?: { sync?: boolean; fetch?: boolean; forceBuild?: boolean }) {
|
||||||
// prevent multiple concurrent builds
|
// prevent multiple concurrent builds
|
||||||
if (this._building) {
|
if (this._building) {
|
||||||
@@ -106,6 +155,8 @@ export class App {
|
|||||||
}
|
}
|
||||||
if (!options?.forceBuild) return;
|
if (!options?.forceBuild) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.runPlugins("beforeBuild");
|
||||||
this._building = true;
|
this._building = true;
|
||||||
|
|
||||||
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
if (options?.sync) this.modules.ctx().flags.sync_required = true;
|
||||||
@@ -117,13 +168,10 @@ export class App {
|
|||||||
guard.registerPermissions(Object.values(SystemPermissions));
|
guard.registerPermissions(Object.values(SystemPermissions));
|
||||||
server.route("/api/system", new SystemController(this).getController());
|
server.route("/api/system", new SystemController(this).getController());
|
||||||
|
|
||||||
// load plugins
|
// emit built event
|
||||||
if (this.plugins.length > 0) {
|
|
||||||
await Promise.all(this.plugins.map((plugin) => plugin(this)));
|
|
||||||
}
|
|
||||||
|
|
||||||
$console.log("App built");
|
$console.log("App built");
|
||||||
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
await this.emgr.emit(new AppBuiltEvent({ app: this }));
|
||||||
|
await this.runPlugins("onBuilt");
|
||||||
|
|
||||||
// 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) {
|
||||||
@@ -151,7 +199,7 @@ export class App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get fetch(): Hono["fetch"] {
|
get fetch(): Hono["fetch"] {
|
||||||
return this.server.fetch;
|
return this.server.fetch as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
get module() {
|
get module() {
|
||||||
@@ -220,15 +268,16 @@ export class App {
|
|||||||
$console.log("App config updated", module);
|
$console.log("App config updated", module);
|
||||||
// @todo: potentially double syncing
|
// @todo: potentially double syncing
|
||||||
await this.build({ sync: true });
|
await this.build({ sync: true });
|
||||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this, module, config }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async onFirstBoot() {
|
protected async onFirstBoot() {
|
||||||
$console.log("App first boot");
|
$console.log("App first boot");
|
||||||
this.trigger_first_boot = true;
|
this.trigger_first_boot = true;
|
||||||
|
await this.runPlugins("onFirstBoot");
|
||||||
}
|
}
|
||||||
|
|
||||||
async onServerInit(server: Hono<ServerEnv>) {
|
protected async onServerInit(server: Hono<ServerEnv>) {
|
||||||
server.use(async (c, next) => {
|
server.use(async (c, next) => {
|
||||||
c.set("app", this);
|
c.set("app", this);
|
||||||
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
|
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
|
||||||
@@ -253,35 +302,35 @@ export class App {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// call server init if set
|
||||||
|
if (this.options?.manager?.onServerInit) {
|
||||||
|
this.options.manager.onServerInit(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.runPlugins("onServerInit", server);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async onModulesBuilt(ctx: ModuleBuildContext) {
|
||||||
|
const results = (await this.runPlugins("schema")) as {
|
||||||
|
name: string;
|
||||||
|
result: ReturnType<typeof prototypeEm>;
|
||||||
|
}[];
|
||||||
|
if (results.length > 0) {
|
||||||
|
for (const { name, result } of results) {
|
||||||
|
if (result) {
|
||||||
|
$console.log(`[Plugin:${name}] schema`);
|
||||||
|
ctx.helper.ensureSchema(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(config: CreateAppConfig = {}) {
|
export function createApp(config: CreateAppConfig = {}) {
|
||||||
let connection: Connection | undefined = undefined;
|
if (!config.connection || !Connection.isConnection(config.connection)) {
|
||||||
|
|
||||||
try {
|
|
||||||
if (Connection.isConnection(config.connection)) {
|
|
||||||
connection = config.connection;
|
|
||||||
} 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);
|
|
||||||
} else {
|
|
||||||
connection = new LibsqlConnection(config.connection);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
connection = new LibsqlConnection({ url: ":memory:" });
|
|
||||||
$console.warn("No connection provided, using in-memory database");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
$console.error("Could not create connection", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!connection) {
|
|
||||||
throw new Error("Invalid connection");
|
throw new Error("Invalid connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new App(connection, config.initialConfig, config.options);
|
return new App(config.connection, config.initialConfig, config.options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
import { registerLocalMediaAdapter } from ".";
|
||||||
import { config } from "bknd/core";
|
import { config } from "bknd/core";
|
||||||
import type { ServeOptions } from "bun";
|
import type { ServeOptions } from "bun";
|
||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
|
import type { App } from "App";
|
||||||
|
|
||||||
type BunEnv = Bun.Env;
|
type BunEnv = Bun.Env;
|
||||||
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||||
@@ -33,8 +34,11 @@ export function createHandler<Env = BunEnv>(
|
|||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
|
let app: App | undefined;
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
const app = await createApp(config, args ?? (process.env as Env), opts);
|
if (!app) {
|
||||||
|
app = await createApp(config, args ?? (process.env as Env), opts);
|
||||||
|
}
|
||||||
return app.fetch(req);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,5 +76,5 @@ export function serve<Env = BunEnv>(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Server is running on http://localhost:${port}`);
|
console.info(`Server is running on http://localhost:${port}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
|
import { bunSqlite } from "./BunSqliteConnection";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import { describe } from "bun:test";
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
|
||||||
|
describe("BunSqliteConnection", () => {
|
||||||
|
connectionTestSuite(bunTestRunner, {
|
||||||
|
makeConnection: () => ({
|
||||||
|
connection: bunSqlite({ database: new Database(":memory:") }),
|
||||||
|
dispose: async () => {},
|
||||||
|
}),
|
||||||
|
rawDialectDetails: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
|
||||||
|
|
||||||
|
export type BunSqliteConnection = GenericSqliteConnection<Database>;
|
||||||
|
export type BunSqliteConnectionConfig = {
|
||||||
|
database: Database;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bunSqlite(config?: BunSqliteConnectionConfig | { url: string }) {
|
||||||
|
let db: Database;
|
||||||
|
if (config) {
|
||||||
|
if ("database" in config) {
|
||||||
|
db = config.database;
|
||||||
|
} else {
|
||||||
|
db = new Database(config.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db = new Database(":memory:");
|
||||||
|
}
|
||||||
|
|
||||||
|
return genericSqlite("bun-sqlite", db, (utils) => {
|
||||||
|
//const fn = cache ? "query" : "prepare";
|
||||||
|
const getStmt = (sql: string) => db.prepare(sql);
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
query: utils.buildQueryFn({
|
||||||
|
all: (sql, parameters) => getStmt(sql).all(...(parameters || [])),
|
||||||
|
run: (sql, parameters) => {
|
||||||
|
const { changes, lastInsertRowid } = getStmt(sql).run(...(parameters || []));
|
||||||
|
return {
|
||||||
|
insertId: utils.parseBigInt(lastInsertRowid),
|
||||||
|
numAffectedRows: utils.parseBigInt(changes),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
close: () => db.close(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +1,3 @@
|
|||||||
export * from "./bun.adapter";
|
export * from "./bun.adapter";
|
||||||
|
export * from "../node/storage";
|
||||||
|
export * from "./connection/BunSqliteConnection";
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { expect, test, mock } from "bun:test";
|
import { expect, test, mock, describe, beforeEach, afterEach, afterAll } from "bun:test";
|
||||||
|
|
||||||
export const bunTestRunner = {
|
export const bunTestRunner = {
|
||||||
|
describe,
|
||||||
expect,
|
expect,
|
||||||
test,
|
test,
|
||||||
mock,
|
mock,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
afterAll,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
/// <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", "_cf_METADATA"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class D1Connection extends SqliteConnection {
|
|
||||||
protected override readonly supported = {
|
|
||||||
batching: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(private config: D1ConnectionConfig) {
|
|
||||||
const plugins = [new ParseJSONResultsPlugin()];
|
|
||||||
|
|
||||||
const kysely = new Kysely({
|
|
||||||
dialect: new CustomD1Dialect({ database: config.binding }),
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
super(kysely, {}, plugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
get client(): D1Database {
|
|
||||||
return this.config.binding;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async batch<Queries extends QB[]>(
|
|
||||||
queries: [...Queries],
|
|
||||||
): Promise<{
|
|
||||||
[K in keyof Queries]: Awaited<ReturnType<Queries[K]["execute"]>>;
|
|
||||||
}> {
|
|
||||||
const db = this.config.binding;
|
|
||||||
|
|
||||||
const res = await db.batch(
|
|
||||||
queries.map((q) => {
|
|
||||||
const { sql, parameters } = q.compile();
|
|
||||||
return db.prepare(sql).bind(...parameters);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// let it run through plugins
|
|
||||||
const kyselyPlugins = new KyselyPluginRunner(this.plugins);
|
|
||||||
const data: any = [];
|
|
||||||
for (const r of res) {
|
|
||||||
const rows = await kyselyPlugins.transformResultRows(r.results);
|
|
||||||
data.push(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
import { makeApp } from "./modes/fresh";
|
import { makeApp } from "./modes/fresh";
|
||||||
import { makeConfig } from "./config";
|
import { makeConfig, type CfMakeConfigArgs } from "./config";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
@@ -13,36 +13,38 @@ describe("cf adapter", () => {
|
|||||||
const DB_URL = ":memory:";
|
const DB_URL = ":memory:";
|
||||||
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
|
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
|
||||||
request: request ?? (null as any),
|
request: request ?? (null as any),
|
||||||
env: env ?? { DB_URL },
|
env: env ?? { url: DB_URL },
|
||||||
ctx: ctx ?? (null as any),
|
ctx: ctx ?? (null as any),
|
||||||
});
|
});
|
||||||
|
|
||||||
it("makes config", async () => {
|
it("makes config", async () => {
|
||||||
expect(
|
const staticConfig = makeConfig(
|
||||||
makeConfig(
|
|
||||||
{
|
{
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
|
initialConfig: { data: { basepath: DB_URL } },
|
||||||
},
|
},
|
||||||
{},
|
$ctx({ DB_URL }),
|
||||||
),
|
);
|
||||||
).toEqual({ connection: { url: DB_URL } });
|
expect(staticConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||||
|
expect(staticConfig.connection).toBeDefined();
|
||||||
|
|
||||||
expect(
|
const dynamicConfig = makeConfig(
|
||||||
makeConfig(
|
|
||||||
{
|
{
|
||||||
app: (env) => ({
|
app: (env) => ({
|
||||||
|
initialConfig: { data: { basepath: env.DB_URL } },
|
||||||
connection: { url: env.DB_URL },
|
connection: { url: env.DB_URL },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
$ctx({ DB_URL }),
|
||||||
DB_URL,
|
);
|
||||||
},
|
expect(dynamicConfig.initialConfig).toEqual({ data: { basepath: DB_URL } });
|
||||||
),
|
expect(dynamicConfig.connection).toBeDefined();
|
||||||
).toEqual({ connection: { url: DB_URL } });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
|
adapterTestSuite<CloudflareBkndConfig, CfMakeConfigArgs<any>>(bunTestRunner, {
|
||||||
makeApp,
|
makeApp: async (c, a, o) => {
|
||||||
|
return await makeApp(c, { env: a } as any, o);
|
||||||
|
},
|
||||||
makeHandler: (c, a, o) => {
|
makeHandler: (c, a, o) => {
|
||||||
return async (request: any) => {
|
return async (request: any) => {
|
||||||
const app = await makeApp(
|
const app = await makeApp(
|
||||||
@@ -50,7 +52,7 @@ describe("cf adapter", () => {
|
|||||||
c ?? {
|
c ?? {
|
||||||
connection: { url: DB_URL },
|
connection: { url: DB_URL },
|
||||||
},
|
},
|
||||||
a,
|
a!,
|
||||||
o,
|
o,
|
||||||
);
|
);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
|
|||||||
@@ -7,8 +7,15 @@ import { getFresh } from "./modes/fresh";
|
|||||||
import { getCached } from "./modes/cached";
|
import { getCached } from "./modes/cached";
|
||||||
import { getDurable } from "./modes/durable";
|
import { getDurable } from "./modes/durable";
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
|
import { $console } from "core/utils";
|
||||||
|
|
||||||
export type CloudflareEnv = object;
|
declare global {
|
||||||
|
namespace Cloudflare {
|
||||||
|
interface Env {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CloudflareEnv = Cloudflare.Env;
|
||||||
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
bindings?: (args: Env) => {
|
bindings?: (args: Env) => {
|
||||||
@@ -16,11 +23,17 @@ export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> &
|
|||||||
dobj?: DurableObjectNamespace;
|
dobj?: DurableObjectNamespace;
|
||||||
db?: D1Database;
|
db?: D1Database;
|
||||||
};
|
};
|
||||||
|
d1?: {
|
||||||
|
session?: boolean;
|
||||||
|
transport?: "header" | "cookie";
|
||||||
|
first?: D1SessionConstraint;
|
||||||
|
};
|
||||||
static?: "kv" | "assets";
|
static?: "kv" | "assets";
|
||||||
key?: string;
|
key?: string;
|
||||||
keepAliveSeconds?: number;
|
keepAliveSeconds?: number;
|
||||||
forceHttps?: boolean;
|
forceHttps?: boolean;
|
||||||
manifest?: string;
|
manifest?: string;
|
||||||
|
registerMedia?: boolean | ((env: Env) => void);
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Context<Env = CloudflareEnv> = {
|
export type Context<Env = CloudflareEnv> = {
|
||||||
@@ -37,7 +50,7 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
if (config.manifest && config.static === "assets") {
|
if (config.manifest && config.static === "assets") {
|
||||||
console.warn("manifest is not useful with static 'assets'");
|
$console.warn("manifest is not useful with static 'assets'");
|
||||||
} else if (!config.manifest && config.static === "kv") {
|
} else if (!config.manifest && config.static === "kv") {
|
||||||
throw new Error("manifest is required with static 'kv'");
|
throw new Error("manifest is required with static 'kv'");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,174 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import { registerMedia } from "./storage/StorageR2Adapter";
|
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||||
import { getBinding } from "./bindings";
|
import { getBinding } from "./bindings";
|
||||||
import { D1Connection } from "./D1Connection";
|
import { d1Sqlite } from "./connection/D1Connection";
|
||||||
|
import { Connection } from "bknd/data";
|
||||||
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||||
import type { ExecutionContext } from "hono";
|
import type { Context, ExecutionContext } from "hono";
|
||||||
|
import { $console } from "core/utils";
|
||||||
|
import { setCookie } from "hono/cookie";
|
||||||
|
import { sqlite } from "bknd/adapter/sqlite";
|
||||||
|
|
||||||
export const constants = {
|
export const constants = {
|
||||||
exec_async_event_id: "cf_register_waituntil",
|
exec_async_event_id: "cf_register_waituntil",
|
||||||
cache_endpoint: "/__bknd/cache",
|
cache_endpoint: "/__bknd/cache",
|
||||||
do_endpoint: "/__bknd/do",
|
do_endpoint: "/__bknd/do",
|
||||||
|
d1_session: {
|
||||||
|
cookie: "cf_d1_session",
|
||||||
|
header: "x-cf-d1-session",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CfMakeConfigArgs<Env extends CloudflareEnv = CloudflareEnv> = {
|
||||||
|
env: Env;
|
||||||
|
ctx?: ExecutionContext;
|
||||||
|
request?: Request;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCookieValue(cookies: string | null, name: string) {
|
||||||
|
if (!cookies) return null;
|
||||||
|
|
||||||
|
for (const cookie of cookies.split("; ")) {
|
||||||
|
const [key, value] = cookie.split("=");
|
||||||
|
if (key === name && value) {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function d1SessionHelper(config: CloudflareBkndConfig<any>) {
|
||||||
|
const headerKey = constants.d1_session.header;
|
||||||
|
const cookieKey = constants.d1_session.cookie;
|
||||||
|
const transport = config.d1?.transport;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (request?: Request): D1SessionBookmark | undefined => {
|
||||||
|
if (!request || !config.d1?.session) return undefined;
|
||||||
|
|
||||||
|
if (!transport || transport === "cookie") {
|
||||||
|
const cookies = request.headers.get("Cookie");
|
||||||
|
if (cookies) {
|
||||||
|
const cookie = getCookieValue(cookies, cookieKey);
|
||||||
|
if (cookie) {
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transport || transport === "header") {
|
||||||
|
if (request.headers.has(headerKey)) {
|
||||||
|
return request.headers.get(headerKey) as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
set: (c: Context, d1?: D1DatabaseSession) => {
|
||||||
|
if (!d1 || !config.d1?.session) return;
|
||||||
|
|
||||||
|
const session = d1.getBookmark();
|
||||||
|
if (session) {
|
||||||
|
if (!transport || transport === "header") {
|
||||||
|
c.header(headerKey, session);
|
||||||
|
}
|
||||||
|
if (!transport || transport === "cookie") {
|
||||||
|
setCookie(c, cookieKey, session, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
|
sameSite: "Lax",
|
||||||
|
maxAge: 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let media_registered: boolean = false;
|
let media_registered: boolean = false;
|
||||||
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args: Env = {} as Env,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
) {
|
) {
|
||||||
if (!media_registered) {
|
if (!media_registered && config.registerMedia !== false) {
|
||||||
registerMedia(args as any);
|
if (typeof config.registerMedia === "function") {
|
||||||
|
config.registerMedia(args?.env as any);
|
||||||
|
} else {
|
||||||
|
registerMedia(args?.env as any);
|
||||||
|
}
|
||||||
media_registered = true;
|
media_registered = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appConfig = makeAdapterConfig(config, args);
|
const appConfig = makeAdapterConfig(config, args?.env);
|
||||||
const bindings = config.bindings?.(args);
|
|
||||||
if (!appConfig.connection) {
|
// if connection instance is given, don't do anything
|
||||||
|
// other than checking if D1 session is defined
|
||||||
|
if (Connection.isConnection(appConfig.connection)) {
|
||||||
|
if (config.d1?.session) {
|
||||||
|
// we cannot guarantee that db was opened with session
|
||||||
|
throw new Error(
|
||||||
|
"D1 session don't work when D1 is directly given as connection. Define it in bindings instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// if connection is given, try to open with unified sqlite adapter
|
||||||
|
} else if (appConfig.connection) {
|
||||||
|
appConfig.connection = sqlite(appConfig.connection);
|
||||||
|
|
||||||
|
// if connection is not given, but env is set
|
||||||
|
// try to make D1 from bindings
|
||||||
|
} else if (args?.env) {
|
||||||
|
const bindings = config.bindings?.(args?.env);
|
||||||
|
const sessionHelper = d1SessionHelper(config);
|
||||||
|
const sessionId = sessionHelper.get(args.request);
|
||||||
|
let session: D1DatabaseSession | undefined;
|
||||||
let db: D1Database | undefined;
|
let db: D1Database | undefined;
|
||||||
|
|
||||||
|
// if db is given in bindings, use it
|
||||||
if (bindings?.db) {
|
if (bindings?.db) {
|
||||||
console.log("Using database from bindings");
|
$console.debug("Using database from bindings");
|
||||||
db = bindings.db;
|
db = bindings.db;
|
||||||
} else if (Object.keys(args).length > 0) {
|
|
||||||
const binding = getBinding(args, "D1Database");
|
// scan for D1Database in args
|
||||||
|
} else {
|
||||||
|
const binding = getBinding(args.env, "D1Database");
|
||||||
if (binding) {
|
if (binding) {
|
||||||
console.log(`Using database from env "${binding.key}"`);
|
$console.debug(`Using database from env "${binding.key}"`);
|
||||||
db = binding.value;
|
db = binding.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if db is found, check if session is requested
|
||||||
if (db) {
|
if (db) {
|
||||||
appConfig.connection = new D1Connection({ binding: db });
|
if (config.d1?.session) {
|
||||||
} else {
|
session = db.withSession(sessionId ?? config.d1?.first);
|
||||||
throw new Error("No database connection given");
|
if (!session) {
|
||||||
|
throw new Error("Couldn't create session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appConfig.connection = d1Sqlite({ binding: session });
|
||||||
|
appConfig.options = {
|
||||||
|
...appConfig.options,
|
||||||
|
manager: {
|
||||||
|
...appConfig.options?.manager,
|
||||||
|
onServerInit: (server) => {
|
||||||
|
server.use(async (c, next) => {
|
||||||
|
sessionHelper.set(c, session);
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
appConfig.connection = d1Sqlite({ binding: db });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Connection.isConnection(appConfig.connection)) {
|
||||||
|
throw new Error("Couldn't find database connection");
|
||||||
}
|
}
|
||||||
|
|
||||||
return appConfig;
|
return appConfig;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
|
||||||
|
import type { QueryResult } from "kysely";
|
||||||
|
|
||||||
|
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
|
||||||
|
|
||||||
|
export type D1ConnectionConfig<DB extends D1Database | D1DatabaseSession = D1Database> = {
|
||||||
|
binding: DB;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function d1Sqlite<DB extends D1Database | D1DatabaseSession = D1Database>(
|
||||||
|
config: D1ConnectionConfig<DB>,
|
||||||
|
) {
|
||||||
|
const db = config.binding;
|
||||||
|
|
||||||
|
return genericSqlite(
|
||||||
|
"d1-sqlite",
|
||||||
|
db,
|
||||||
|
(utils) => {
|
||||||
|
const getStmt = (sql: string, parameters?: any[] | readonly any[]) =>
|
||||||
|
db.prepare(sql).bind(...(parameters || []));
|
||||||
|
|
||||||
|
const mapResult = (res: D1Result<any>): QueryResult<any> => {
|
||||||
|
if (res.error) {
|
||||||
|
throw new Error(res.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numAffectedRows =
|
||||||
|
res.meta.changes > 0 ? utils.parseBigInt(res.meta.changes) : undefined;
|
||||||
|
const insertId = res.meta.last_row_id
|
||||||
|
? utils.parseBigInt(res.meta.last_row_id)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
insertId,
|
||||||
|
numAffectedRows,
|
||||||
|
rows: res.results,
|
||||||
|
// @ts-ignore
|
||||||
|
meta: res.meta,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
batch: async (stmts) => {
|
||||||
|
const res = await db.batch(
|
||||||
|
stmts.map(({ sql, parameters }) => {
|
||||||
|
return getStmt(sql, parameters);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return res.map(mapResult);
|
||||||
|
},
|
||||||
|
query: utils.buildQueryFn({
|
||||||
|
all: async (sql, parameters) => {
|
||||||
|
const prep = getStmt(sql, parameters);
|
||||||
|
return mapResult(await prep.all()).rows;
|
||||||
|
},
|
||||||
|
run: async (sql, parameters) => {
|
||||||
|
const prep = getStmt(sql, parameters);
|
||||||
|
return mapResult(await prep.run());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
close: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
supports: {
|
||||||
|
batching: true,
|
||||||
|
softscans: false,
|
||||||
|
},
|
||||||
|
excludeTables: ["_cf_KV", "_cf_METADATA"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
|
||||||
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
import { d1Sqlite } from "./D1Connection";
|
||||||
|
|
||||||
|
describe("d1Sqlite", async () => {
|
||||||
|
connectionTestSuite(viTestRunner, {
|
||||||
|
makeConnection: async () => {
|
||||||
|
const mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
script: "export default { async fetch() { return new Response(null); } }",
|
||||||
|
d1Databases: ["DB"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const binding = (await mf.getD1Database("DB")) as D1Database;
|
||||||
|
return {
|
||||||
|
connection: d1Sqlite({ binding }),
|
||||||
|
dispose: () => mf.dispose(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rawDialectDetails: [
|
||||||
|
"meta.served_by",
|
||||||
|
"meta.duration",
|
||||||
|
"meta.changes",
|
||||||
|
"meta.changed_db",
|
||||||
|
"meta.size_after",
|
||||||
|
"meta.rows_read",
|
||||||
|
"meta.rows_written",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { genericSqlite, type GenericSqliteConnection } from "bknd/data";
|
||||||
|
import type { QueryResult } from "kysely";
|
||||||
|
|
||||||
|
export type D1SqliteConnection = GenericSqliteConnection<D1Database>;
|
||||||
|
export type DurableObjecSql = DurableObjectState["storage"]["sql"];
|
||||||
|
|
||||||
|
export type D1ConnectionConfig<DB extends DurableObjecSql> =
|
||||||
|
| DurableObjectState
|
||||||
|
| {
|
||||||
|
sql: DB;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function doSqlite<DB extends DurableObjecSql>(config: D1ConnectionConfig<DB>) {
|
||||||
|
const db = "sql" in config ? config.sql : config.storage.sql;
|
||||||
|
|
||||||
|
return genericSqlite(
|
||||||
|
"do-sqlite",
|
||||||
|
db,
|
||||||
|
(utils) => {
|
||||||
|
// must be async to work with the miniflare mock
|
||||||
|
const getStmt = async (sql: string, parameters?: any[] | readonly any[]) =>
|
||||||
|
await db.exec(sql, ...(parameters || []));
|
||||||
|
|
||||||
|
const mapResult = (
|
||||||
|
cursor: SqlStorageCursor<Record<string, SqlStorageValue>>,
|
||||||
|
): QueryResult<any> => {
|
||||||
|
const numAffectedRows =
|
||||||
|
cursor.rowsWritten > 0 ? utils.parseBigInt(cursor.rowsWritten) : undefined;
|
||||||
|
const insertId = undefined;
|
||||||
|
|
||||||
|
const obj = {
|
||||||
|
insertId,
|
||||||
|
numAffectedRows,
|
||||||
|
rows: cursor.toArray() || [],
|
||||||
|
// @ts-ignore
|
||||||
|
meta: {
|
||||||
|
rowsWritten: cursor.rowsWritten,
|
||||||
|
rowsRead: cursor.rowsRead,
|
||||||
|
databaseSize: db.databaseSize,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
//console.info("mapResult", obj);
|
||||||
|
return obj;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
batch: async (stmts) => {
|
||||||
|
// @todo: maybe wrap in a transaction?
|
||||||
|
// because d1 implicitly does a transaction on batch
|
||||||
|
return Promise.all(
|
||||||
|
stmts.map(async (stmt) => {
|
||||||
|
return mapResult(await getStmt(stmt.sql, stmt.parameters));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
query: utils.buildQueryFn({
|
||||||
|
all: async (sql, parameters) => {
|
||||||
|
const prep = getStmt(sql, parameters);
|
||||||
|
return mapResult(await prep).rows;
|
||||||
|
},
|
||||||
|
run: async (sql, parameters) => {
|
||||||
|
const prep = getStmt(sql, parameters);
|
||||||
|
return mapResult(await prep);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
close: () => {},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
supports: {
|
||||||
|
batching: true,
|
||||||
|
softscans: false,
|
||||||
|
},
|
||||||
|
excludeTables: ["_cf_KV", "_cf_METADATA"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
|
||||||
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
import { doSqlite } from "./DoConnection";
|
||||||
|
|
||||||
|
const script = `
|
||||||
|
import { DurableObject } from "cloudflare:workers";
|
||||||
|
|
||||||
|
export class TestObject extends DurableObject {
|
||||||
|
constructor(ctx, env) {
|
||||||
|
super(ctx, env);
|
||||||
|
this.storage = ctx.storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exec(sql, ...parameters) {
|
||||||
|
//return { sql, parameters }
|
||||||
|
const cursor = this.storage.sql.exec(sql, ...parameters);
|
||||||
|
return {
|
||||||
|
rows: cursor.toArray() || [],
|
||||||
|
rowsWritten: cursor.rowsWritten,
|
||||||
|
rowsRead: cursor.rowsRead,
|
||||||
|
databaseSize: this.storage.sql.databaseSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async databaseSize() {
|
||||||
|
return this.storage.sql.databaseSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const stub = env.TEST_OBJECT.get(env.TEST_OBJECT.idFromName("test"));
|
||||||
|
return stub.fetch(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("doSqlite", async () => {
|
||||||
|
connectionTestSuite(viTestRunner, {
|
||||||
|
makeConnection: async () => {
|
||||||
|
const mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
durableObjects: { TEST_OBJECT: { className: "TestObject", useSQLite: true } },
|
||||||
|
script,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ns = await mf.getDurableObjectNamespace("TEST_OBJECT");
|
||||||
|
const id = ns.idFromName("test");
|
||||||
|
const stub = ns.get(id) as unknown as DurableObjectStub<
|
||||||
|
Rpc.DurableObjectBranded & {
|
||||||
|
exec: (sql: string, ...parameters: any[]) => Promise<any>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const stubs: any[] = [];
|
||||||
|
const mock = {
|
||||||
|
databaseSize: 0,
|
||||||
|
exec: async function (sql: string, ...parameters: any[]) {
|
||||||
|
// @ts-ignore
|
||||||
|
const result = (await stub.exec(sql, ...parameters)) as any;
|
||||||
|
this.databaseSize = result.databaseSize;
|
||||||
|
stubs.push(result);
|
||||||
|
return {
|
||||||
|
toArray: () => result.rows,
|
||||||
|
rowsWritten: result.rowsWritten,
|
||||||
|
rowsRead: result.rowsRead,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection: doSqlite({ sql: mock as any }),
|
||||||
|
dispose: async () => {
|
||||||
|
await Promise.all(
|
||||||
|
stubs.map((stub) => {
|
||||||
|
try {
|
||||||
|
return stub[Symbol.dispose]();
|
||||||
|
} catch (e) {}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await mf.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rawDialectDetails: ["meta.rowsWritten", "meta.rowsRead", "meta.databaseSize"],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ICacheDriver } from "core/drivers";
|
||||||
|
|
||||||
|
interface WorkersKVCacheOptions {
|
||||||
|
// default time-to-live in seconds
|
||||||
|
defaultTTL?: number;
|
||||||
|
// prefix for the cache key
|
||||||
|
cachePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkersKVCacheDriver implements ICacheDriver {
|
||||||
|
protected readonly kv: KVNamespace;
|
||||||
|
protected readonly defaultTTL?: number;
|
||||||
|
protected readonly cachePrefix: string;
|
||||||
|
|
||||||
|
constructor(kv: KVNamespace, options: WorkersKVCacheOptions = {}) {
|
||||||
|
this.kv = kv;
|
||||||
|
this.cachePrefix = options.cachePrefix ?? "";
|
||||||
|
this.defaultTTL = options.defaultTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getKey(key: string): string {
|
||||||
|
return this.cachePrefix + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | undefined> {
|
||||||
|
const value = await this.kv.get(this.getKey(key));
|
||||||
|
return value === null ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||||
|
let expirationTtl = ttl ?? this.defaultTTL;
|
||||||
|
if (expirationTtl) {
|
||||||
|
expirationTtl = Math.max(expirationTtl, 60);
|
||||||
|
}
|
||||||
|
await this.kv.put(this.getKey(key), value, { expirationTtl: expirationTtl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await this.kv.delete(this.getKey(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheWorkersKV = (kv: KVNamespace, options?: WorkersKVCacheOptions) => {
|
||||||
|
return new WorkersKVCacheDriver(kv, options);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, vi, afterAll, beforeAll } from "vitest";
|
||||||
|
import { cacheWorkersKV } from "./cache";
|
||||||
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
|
||||||
|
describe("cacheWorkersKV", async () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
script: "export default { async fetch() { return new Response(null); } }",
|
||||||
|
kvNamespaces: ["KV"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const kv = (await mf.getKVNamespace("KV")) as unknown as KVNamespace;
|
||||||
|
|
||||||
|
cacheDriverTestSuite(viTestRunner, {
|
||||||
|
makeCache: () => cacheWorkersKV(kv),
|
||||||
|
setTime: (ms: number) => {
|
||||||
|
vi.advanceTimersByTime(ms);
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
minTTL: 60,
|
||||||
|
// doesn't work with miniflare
|
||||||
|
skipTTL: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
import { d1Sqlite, type D1ConnectionConfig } from "./connection/D1Connection";
|
||||||
|
|
||||||
export * from "./cloudflare-workers.adapter";
|
export * from "./cloudflare-workers.adapter";
|
||||||
export { makeApp, getFresh } from "./modes/fresh";
|
export { makeApp, getFresh } 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 { d1Sqlite, type D1ConnectionConfig };
|
||||||
export {
|
export {
|
||||||
getBinding,
|
getBinding,
|
||||||
getBindings,
|
getBindings,
|
||||||
@@ -12,7 +12,13 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
|
export { constants } from "./config";
|
||||||
|
export { StorageR2Adapter } from "./storage/StorageR2Adapter";
|
||||||
|
export { registries } from "bknd";
|
||||||
|
|
||||||
export function d1(config: D1ConnectionConfig) {
|
// for compatibility with old code
|
||||||
return new D1Connection(config);
|
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
||||||
|
config: D1ConnectionConfig<DB>,
|
||||||
|
) {
|
||||||
|
return d1Sqlite<DB>(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { makeConfig, registerAsyncsExecutionContext, constants } from "../config
|
|||||||
|
|
||||||
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
{ env, ctx, ...args }: Context<Env>,
|
args: Context<Env>,
|
||||||
) {
|
) {
|
||||||
|
const { env, ctx } = args;
|
||||||
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";
|
||||||
@@ -20,7 +21,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
|
|
||||||
const app = await createRuntimeApp(
|
const app = await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...makeConfig(config, env),
|
...makeConfig(config, args),
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
registerAsyncsExecutionContext(app, ctx);
|
registerAsyncsExecutionContext(app, ctx);
|
||||||
@@ -41,7 +42,7 @@ export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
await config.beforeBuild?.(app);
|
await config.beforeBuild?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ env, ctx, ...args },
|
args,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!cachedConfig) {
|
if (!cachedConfig) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { App, CreateAppConfig } from "bknd";
|
|||||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||||
import { constants, registerAsyncsExecutionContext } from "../config";
|
import { constants, registerAsyncsExecutionContext } from "../config";
|
||||||
|
import { $console } from "core/utils";
|
||||||
|
|
||||||
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
@@ -13,7 +14,7 @@ export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
const key = config.key ?? "app";
|
const key = config.key ?? "app";
|
||||||
|
|
||||||
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
|
if ([config.onBuilt, config.beforeBuild].some((x) => x)) {
|
||||||
console.log("onBuilt and beforeBuild are not supported with DurableObject mode");
|
$console.warn("onBuilt and beforeBuild are not supported with DurableObject mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -63,7 +64,7 @@ export class DurableBkndApp extends DurableObject {
|
|||||||
"type" in config.connection &&
|
"type" in config.connection &&
|
||||||
config.connection.type === "libsql"
|
config.connection.type === "libsql"
|
||||||
) {
|
) {
|
||||||
config.connection.config.protocol = "wss";
|
//config.connection.config.protocol = "wss";
|
||||||
}
|
}
|
||||||
|
|
||||||
this.app = await createRuntimeApp({
|
this.app = await createRuntimeApp({
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||||
import { makeConfig, registerAsyncsExecutionContext } from "../config";
|
import { makeConfig, registerAsyncsExecutionContext, type CfMakeConfigArgs } from "../config";
|
||||||
|
|
||||||
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env>,
|
config: CloudflareBkndConfig<Env>,
|
||||||
args: Env = {} as Env,
|
args?: CfMakeConfigArgs<Env>,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
|
return await createRuntimeApp<Env>(makeConfig(config, args), args?.env, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
@@ -20,10 +20,10 @@ export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
...config,
|
...config,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
registerAsyncsExecutionContext(app, ctx.ctx);
|
registerAsyncsExecutionContext(app, ctx.ctx);
|
||||||
config.onBuilt?.(app);
|
await config.onBuilt?.(app);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ctx.env,
|
ctx,
|
||||||
opts,
|
opts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { createWriteStream, readFileSync } from "node:fs";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { Miniflare } from "miniflare";
|
|
||||||
import { StorageR2Adapter } from "./StorageR2Adapter";
|
|
||||||
import { adapterTestSuite } from "media";
|
|
||||||
import { nodeTestRunner } from "adapter/node";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
// https://github.com/nodejs/node/issues/44372#issuecomment-1736530480
|
|
||||||
console.log = async (message: any) => {
|
|
||||||
const tty = createWriteStream("/dev/tty");
|
|
||||||
const msg = typeof message === "string" ? message : JSON.stringify(message, null, 2);
|
|
||||||
return tty.write(`${msg}\n`);
|
|
||||||
};
|
|
||||||
|
|
||||||
test("StorageR2Adapter", async () => {
|
|
||||||
const mf = new Miniflare({
|
|
||||||
modules: true,
|
|
||||||
script: "export default { async fetch() { return new Response(null); } }",
|
|
||||||
r2Buckets: ["BUCKET"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const bucket = (await mf.getR2Bucket("BUCKET")) as unknown as R2Bucket;
|
|
||||||
const adapter = new StorageR2Adapter(bucket);
|
|
||||||
|
|
||||||
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
|
|
||||||
const buffer = readFileSync(path.join(basePath, "image.png"));
|
|
||||||
const file = new File([buffer], "image.png", { type: "image/png" });
|
|
||||||
|
|
||||||
await adapterTestSuite(nodeTestRunner, adapter, file);
|
|
||||||
await mf.dispose();
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { registries } from "bknd";
|
import { registries } from "bknd";
|
||||||
import { isDebug } from "bknd/core";
|
import { isDebug } from "bknd/core";
|
||||||
import { StringEnum, Type } from "bknd/utils";
|
// @ts-ignore
|
||||||
|
import { StringEnum } from "bknd/utils";
|
||||||
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
|
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
|
||||||
import { getBindings } from "../bindings";
|
import { getBindings } from "../bindings";
|
||||||
|
import * as tb from "@sinclair/typebox";
|
||||||
|
const { Type } = tb;
|
||||||
|
|
||||||
export function makeSchema(bindings: string[] = []) {
|
export function makeSchema(bindings: string[] = []) {
|
||||||
return Type.Object(
|
return Type.Object(
|
||||||
@@ -61,46 +64,49 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
|
|
||||||
async putObject(key: string, body: FileBody) {
|
async putObject(key: string, body: FileBody) {
|
||||||
try {
|
try {
|
||||||
const res = await this.bucket.put(key, body);
|
const res = await this.bucket.put(this.getKey(key), body);
|
||||||
return res?.etag;
|
return res?.etag;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async listObjects(
|
async listObjects(prefix = ""): Promise<{ key: string; last_modified: Date; size: number }[]> {
|
||||||
prefix?: string,
|
const list = await this.bucket.list({ limit: 50, prefix: this.getKey(prefix) });
|
||||||
): Promise<{ key: string; last_modified: Date; size: number }[]> {
|
|
||||||
const list = await this.bucket.list({ limit: 50 });
|
|
||||||
return list.objects.map((item) => ({
|
return list.objects.map((item) => ({
|
||||||
key: item.key,
|
key: item.key.replace(this.getKey(""), ""),
|
||||||
size: item.size,
|
size: item.size,
|
||||||
last_modified: item.uploaded,
|
last_modified: item.uploaded,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async headObject(key: string): Promise<R2Object | null> {
|
private async headObject(key: string): Promise<R2Object | null> {
|
||||||
return await this.bucket.head(key);
|
return await this.bucket.head(this.getKey(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
async objectExists(key: string): Promise<boolean> {
|
async objectExists(key: string): Promise<boolean> {
|
||||||
return (await this.headObject(key)) !== null;
|
return (await this.headObject(key)) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 key = this.getKey(_key);
|
||||||
|
|
||||||
const responseHeaders = new Headers({
|
const responseHeaders = new Headers({
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Content-Type": guess(key),
|
"Content-Type": guess(key),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const range = headers.has("range");
|
||||||
|
|
||||||
//console.log("getObject:headers", headersToObject(headers));
|
//console.log("getObject:headers", headersToObject(headers));
|
||||||
if (headers.has("range")) {
|
if (range) {
|
||||||
const options = isDebug()
|
const options = isDebug()
|
||||||
? {} // miniflare doesn't support range requests
|
? {} // miniflare doesn't support range requests
|
||||||
: {
|
: {
|
||||||
range: headers,
|
range: headers,
|
||||||
onlyIf: headers,
|
onlyIf: headers,
|
||||||
};
|
};
|
||||||
|
|
||||||
object = (await this.bucket.get(key, options)) as R2ObjectBody;
|
object = (await this.bucket.get(key, options)) as R2ObjectBody;
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
@@ -122,21 +128,20 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("response headers:before", headersToObject(responseHeaders));
|
|
||||||
this.writeHttpMetadata(responseHeaders, object);
|
this.writeHttpMetadata(responseHeaders, object);
|
||||||
responseHeaders.set("etag", object.httpEtag);
|
responseHeaders.set("etag", object.httpEtag);
|
||||||
responseHeaders.set("Content-Length", String(object.size));
|
responseHeaders.set("Content-Length", String(object.size));
|
||||||
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
|
responseHeaders.set("Last-Modified", object.uploaded.toUTCString());
|
||||||
//console.log("response headers:after", headersToObject(responseHeaders));
|
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
status: object.range ? 206 : 200,
|
status: range ? 206 : 200,
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeHttpMetadata(headers: Headers, object: R2Object | R2ObjectBody): void {
|
private writeHttpMetadata(headers: Headers, object: R2Object | R2ObjectBody): void {
|
||||||
let metadata = object.httpMetadata;
|
let metadata = object.httpMetadata;
|
||||||
|
|
||||||
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 = {
|
||||||
@@ -163,13 +168,17 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteObject(key: string): Promise<void> {
|
async deleteObject(key: string): Promise<void> {
|
||||||
await this.bucket.delete(key);
|
await this.bucket.delete(this.getKey(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
getObjectUrl(key: string): string {
|
getObjectUrl(key: string): string {
|
||||||
throw new Error("Method getObjectUrl not implemented.");
|
throw new Error("Method getObjectUrl not implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getKey(key: string) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON(secrets?: boolean) {
|
||||||
return {
|
return {
|
||||||
type: this.getName(),
|
type: this.getName(),
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
import { StorageR2Adapter } from "./StorageR2Adapter";
|
||||||
|
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, afterAll, test, expect } from "vitest";
|
||||||
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
|
||||||
|
let mf: Miniflare | undefined;
|
||||||
|
describe("StorageR2Adapter", async () => {
|
||||||
|
mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
script: "export default { async fetch() { return new Response(null); } }",
|
||||||
|
r2Buckets: ["BUCKET"],
|
||||||
|
});
|
||||||
|
const bucket = (await mf?.getR2Bucket("BUCKET")) as unknown as R2Bucket;
|
||||||
|
|
||||||
|
test("test", () => {
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
});
|
||||||
|
const adapter = new StorageR2Adapter(bucket);
|
||||||
|
|
||||||
|
const basePath = path.resolve(import.meta.dirname, "../../../../__test__/_assets");
|
||||||
|
const buffer = readFileSync(path.join(basePath, "image.png"));
|
||||||
|
const file = new File([buffer], "image.png", { type: "image/png" });
|
||||||
|
|
||||||
|
await adapterTestSuite(viTestRunner, adapter, file);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mf?.dispose();
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
|
||||||
|
|
||||||
|
export default defineWorkersConfig({
|
||||||
|
test: {
|
||||||
|
poolOptions: {
|
||||||
|
workers: {
|
||||||
|
miniflare: {
|
||||||
|
compatibilityDate: "2025-06-04",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: ["**/*.vi-test.ts", "**/*.vitest.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { App, type CreateAppConfig } from "bknd";
|
import { App, type CreateAppConfig } from "bknd";
|
||||||
import { config as $config } from "bknd/core";
|
import { config as $config } from "bknd/core";
|
||||||
|
import { $console } from "bknd/utils";
|
||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import type { AdminControllerOptions } from "modules/server/AdminController";
|
import type { AdminControllerOptions } from "modules/server/AdminController";
|
||||||
|
import { Connection } from "bknd/data";
|
||||||
|
|
||||||
|
export { Connection } from "bknd/data";
|
||||||
|
|
||||||
export type BkndConfig<Args = any> = CreateAppConfig & {
|
export type BkndConfig<Args = any> = CreateAppConfig & {
|
||||||
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
app?: CreateAppConfig | ((args: Args) => CreateAppConfig);
|
||||||
@@ -59,7 +63,21 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
const id = opts?.id ?? "app";
|
const id = opts?.id ?? "app";
|
||||||
let app = apps.get(id);
|
let app = apps.get(id);
|
||||||
if (!app || opts?.force) {
|
if (!app || opts?.force) {
|
||||||
app = App.create(makeConfig(config, args));
|
const appConfig = makeConfig(config, args);
|
||||||
|
if (!appConfig.connection || !Connection.isConnection(appConfig.connection)) {
|
||||||
|
let connection: Connection | undefined;
|
||||||
|
if (Connection.isConnection(config.connection)) {
|
||||||
|
connection = config.connection;
|
||||||
|
} else {
|
||||||
|
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||||
|
const conf = config.connection ?? { url: ":memory:" };
|
||||||
|
connection = sqlite(conf);
|
||||||
|
$console.info(`Using ${connection.name} connection`, conf.url);
|
||||||
|
}
|
||||||
|
appConfig.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
app = App.create(appConfig);
|
||||||
apps.set(id, app);
|
apps.set(id, app);
|
||||||
}
|
}
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { isNode } from "bknd/utils";
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
type NextjsEnv = NextApiRequest["env"];
|
type NextjsEnv = NextApiRequest["env"];
|
||||||
|
|
||||||
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||||
cleanRequest?: { searchParams?: string[] };
|
cleanRequest?: { searchParams?: string[] };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { genericSqlite } from "bknd/data";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
|
||||||
|
export type NodeSqliteConnectionConfig = {
|
||||||
|
database: DatabaseSync;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function nodeSqlite(config?: NodeSqliteConnectionConfig | { url: string }) {
|
||||||
|
let db: DatabaseSync;
|
||||||
|
if (config) {
|
||||||
|
if ("database" in config) {
|
||||||
|
db = config.database;
|
||||||
|
} else {
|
||||||
|
db = new DatabaseSync(config.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
db = new DatabaseSync(":memory:");
|
||||||
|
}
|
||||||
|
|
||||||
|
return genericSqlite(
|
||||||
|
"node-sqlite",
|
||||||
|
db,
|
||||||
|
(utils) => {
|
||||||
|
const getStmt = (sql: string) => {
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
//stmt.setReadBigInts(true);
|
||||||
|
return stmt;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
query: utils.buildQueryFn({
|
||||||
|
all: (sql, parameters = []) => getStmt(sql).all(...parameters),
|
||||||
|
run: (sql, parameters = []) => {
|
||||||
|
const { changes, lastInsertRowid } = getStmt(sql).run(...parameters);
|
||||||
|
return {
|
||||||
|
insertId: utils.parseBigInt(lastInsertRowid),
|
||||||
|
numAffectedRows: utils.parseBigInt(changes),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
close: () => db.close(),
|
||||||
|
iterator: (isSelect, sql, parameters = []) => {
|
||||||
|
if (!isSelect) {
|
||||||
|
throw new Error("Only support select in stream()");
|
||||||
|
}
|
||||||
|
return getStmt(sql).iterate(...parameters) as any;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
supports: {
|
||||||
|
batching: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { nodeSqlite } from "./NodeSqliteConnection";
|
||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
|
import { describe } from "vitest";
|
||||||
|
import { viTestRunner } from "../vitest";
|
||||||
|
|
||||||
|
describe("NodeSqliteConnection", () => {
|
||||||
|
connectionTestSuite(viTestRunner, {
|
||||||
|
makeConnection: () => ({
|
||||||
|
connection: nodeSqlite({ database: new DatabaseSync(":memory:") }),
|
||||||
|
dispose: async () => {},
|
||||||
|
}),
|
||||||
|
rawDialectDetails: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,3 @@
|
|||||||
import { registries } from "bknd";
|
|
||||||
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
|
|
||||||
|
|
||||||
export * from "./node.adapter";
|
export * from "./node.adapter";
|
||||||
export { StorageLocalAdapter, type LocalAdapterConfig };
|
export * from "./storage";
|
||||||
export { nodeTestRunner } from "./test";
|
export * from "./connection/NodeSqliteConnection";
|
||||||
|
|
||||||
let registered = false;
|
|
||||||
export function registerLocalMediaAdapter() {
|
|
||||||
if (!registered) {
|
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
|
||||||
registered = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (config: Partial<LocalAdapterConfig> = {}) => {
|
|
||||||
const adapter = new StorageLocalAdapter(config);
|
|
||||||
return adapter.toJSON(true);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterAll, beforeAll, describe } from "bun:test";
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
import * as node from "./node.adapter";
|
import { createApp, createHandler } from "./node.adapter";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
@@ -9,7 +9,7 @@ afterAll(enableConsoleLog);
|
|||||||
|
|
||||||
describe("node adapter (bun)", () => {
|
describe("node adapter (bun)", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: node.createApp,
|
makeApp: createApp,
|
||||||
makeHandler: node.createHandler,
|
makeHandler: createHandler,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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 { registerLocalMediaAdapter } from "adapter/node/storage";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import { config as $config } from "bknd/core";
|
import { config as $config } from "bknd/core";
|
||||||
|
import { $console } from "core/utils";
|
||||||
|
import type { App } from "App";
|
||||||
|
|
||||||
type NodeEnv = NodeJS.ProcessEnv;
|
type NodeEnv = NodeJS.ProcessEnv;
|
||||||
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||||
@@ -44,8 +46,11 @@ export function createHandler<Env = NodeEnv>(
|
|||||||
args: Env = {} as Env,
|
args: Env = {} as Env,
|
||||||
opts?: RuntimeOptions,
|
opts?: RuntimeOptions,
|
||||||
) {
|
) {
|
||||||
|
let app: App | undefined;
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
const app = await createApp(config, args ?? (process.env as Env), opts);
|
if (!app) {
|
||||||
|
app = await createApp(config, args ?? (process.env as Env), opts);
|
||||||
|
}
|
||||||
return app.fetch(req);
|
return app.fetch(req);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -62,7 +67,7 @@ export function serve<Env = NodeEnv>(
|
|||||||
fetch: createHandler(config, args, opts),
|
fetch: createHandler(config, args, opts),
|
||||||
},
|
},
|
||||||
(connInfo) => {
|
(connInfo) => {
|
||||||
console.log(`Server is running on http://localhost:${connInfo.port}`);
|
$console.log(`Server is running on http://localhost:${connInfo.port}`);
|
||||||
listener?.(connInfo);
|
listener?.(connInfo);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-5
@@ -1,14 +1,14 @@
|
|||||||
import { describe, before, after } from "node:test";
|
import { describe, beforeAll, afterAll } from "vitest";
|
||||||
import * as node from "./node.adapter";
|
import * as node from "./node.adapter";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { nodeTestRunner } from "adapter/node";
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
|
||||||
before(() => disableConsoleLog());
|
beforeAll(() => disableConsoleLog());
|
||||||
after(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("node adapter", () => {
|
describe("node adapter", () => {
|
||||||
adapterTestSuite(nodeTestRunner, {
|
adapterTestSuite(viTestRunner, {
|
||||||
makeApp: node.createApp,
|
makeApp: node.createApp,
|
||||||
makeHandler: node.createHandler,
|
makeHandler: node.createHandler,
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
import { readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
import { type Static, Type, isFile, parse } from "bknd/utils";
|
import { type Static, isFile, parse } from "bknd/utils";
|
||||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
|
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
|
||||||
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
|
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
|
||||||
|
import * as tb from "@sinclair/typebox";
|
||||||
|
const { Type } = tb;
|
||||||
|
|
||||||
export const localAdapterConfig = Type.Object(
|
export const localAdapterConfig = Type.Object(
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-3
@@ -1,5 +1,6 @@
|
|||||||
import { describe } from "node:test";
|
import { describe } from "vitest";
|
||||||
import { StorageLocalAdapter, nodeTestRunner } from "adapter/node";
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
import { StorageLocalAdapter } from "adapter/node";
|
||||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -13,5 +14,5 @@ describe("StorageLocalAdapter (node)", async () => {
|
|||||||
path: path.join(basePath, "tmp"),
|
path: path.join(basePath, "tmp"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await adapterTestSuite(nodeTestRunner, adapter, file);
|
await adapterTestSuite(viTestRunner, adapter, file);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { registries } from "bknd";
|
||||||
|
import { type LocalAdapterConfig, StorageLocalAdapter } from "./StorageLocalAdapter";
|
||||||
|
|
||||||
|
export * from "./StorageLocalAdapter";
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
export function registerLocalMediaAdapter() {
|
||||||
|
if (!registered) {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (config: Partial<LocalAdapterConfig> = {}) => {
|
||||||
|
const adapter = new StorageLocalAdapter(config);
|
||||||
|
return adapter.toJSON(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import nodeAssert from "node:assert/strict";
|
import nodeAssert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test, describe, beforeEach, afterEach } from "node:test";
|
||||||
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
|
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
|
||||||
|
|
||||||
// Track mock function calls
|
// Track mock function calls
|
||||||
@@ -85,6 +85,7 @@ nodeTest.skipIf = (condition: boolean): Test => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const nodeTestRunner: TestRunner = {
|
export const nodeTestRunner: TestRunner = {
|
||||||
|
describe,
|
||||||
test: nodeTest,
|
test: nodeTest,
|
||||||
mock: createMockFunction,
|
mock: createMockFunction,
|
||||||
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
|
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
|
||||||
@@ -96,4 +97,7 @@ export const nodeTestRunner: TestRunner = {
|
|||||||
reject: (r) => nodeTestMatcher(r, failMsg),
|
reject: (r) => nodeTestMatcher(r, failMsg),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
beforeEach: beforeEach,
|
||||||
|
afterEach: afterEach,
|
||||||
|
afterAll: () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { TestFn, TestRunner, Test } from "core/test";
|
||||||
|
import { describe, test, expect, vi, beforeEach, afterEach, afterAll } from "vitest";
|
||||||
|
|
||||||
|
function vitestTest(label: string, fn: TestFn, options?: any) {
|
||||||
|
return test(label, fn as any);
|
||||||
|
}
|
||||||
|
vitestTest.if = (condition: boolean): Test => {
|
||||||
|
if (condition) {
|
||||||
|
return vitestTest;
|
||||||
|
}
|
||||||
|
return (() => {}) as any;
|
||||||
|
};
|
||||||
|
vitestTest.skip = (label: string, fn: TestFn) => {
|
||||||
|
return test.skip(label, fn as any);
|
||||||
|
};
|
||||||
|
vitestTest.skipIf = (condition: boolean): Test => {
|
||||||
|
if (condition) {
|
||||||
|
return (() => {}) as any;
|
||||||
|
}
|
||||||
|
return vitestTest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const vitestExpect = <T = unknown>(actual: T, parentFailMsg?: string) => {
|
||||||
|
return {
|
||||||
|
toEqual: (expected: T, failMsg = parentFailMsg) => {
|
||||||
|
expect(actual, failMsg).toEqual(expected);
|
||||||
|
},
|
||||||
|
toBe: (expected: T, failMsg = parentFailMsg) => {
|
||||||
|
expect(actual, failMsg).toBe(expected);
|
||||||
|
},
|
||||||
|
toBeString: () => expect(typeof actual, parentFailMsg).toBe("string"),
|
||||||
|
toBeUndefined: () => expect(actual, parentFailMsg).toBeUndefined(),
|
||||||
|
toBeDefined: () => expect(actual, parentFailMsg).toBeDefined(),
|
||||||
|
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
|
||||||
|
const e = Array.isArray(expected) ? expected : [expected];
|
||||||
|
expect(actual, failMsg).toBeOneOf(e);
|
||||||
|
},
|
||||||
|
toHaveBeenCalled: () => expect(actual, parentFailMsg).toHaveBeenCalled(),
|
||||||
|
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
|
||||||
|
expect(actual, failMsg).toHaveBeenCalledTimes(expected);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viTestRunner: TestRunner = {
|
||||||
|
describe,
|
||||||
|
test: vitestTest,
|
||||||
|
expect: vitestExpect as any,
|
||||||
|
mock: (fn) => vi.fn(fn),
|
||||||
|
beforeEach: beforeEach,
|
||||||
|
afterEach: afterEach,
|
||||||
|
afterAll: afterAll,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Connection } from "bknd/data";
|
||||||
|
import { bunSqlite } from "../bun/connection/BunSqliteConnection";
|
||||||
|
|
||||||
|
export function sqlite(config?: { url: string }): Connection {
|
||||||
|
return bunSqlite(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { type Connection, libsql } from "bknd/data";
|
||||||
|
|
||||||
|
export function sqlite(config: { url: string }): Connection {
|
||||||
|
return libsql(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Connection } from "bknd/data";
|
||||||
|
import { nodeSqlite } from "../node/connection/NodeSqliteConnection";
|
||||||
|
|
||||||
|
export function sqlite(config?: { url: string }): Connection {
|
||||||
|
return nodeSqlite(config);
|
||||||
|
}
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import {
|
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
||||||
type DevServerOptions,
|
|
||||||
default as honoViteDevServer,
|
|
||||||
} from "@hono/vite-dev-server";
|
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import {
|
import { type RuntimeBkndConfig, createRuntimeApp, type FrameworkOptions } from "bknd/adapter";
|
||||||
type RuntimeBkndConfig,
|
|
||||||
createRuntimeApp,
|
|
||||||
type FrameworkOptions,
|
|
||||||
} from "bknd/adapter";
|
|
||||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
import { devServerConfig } from "./dev-server-config";
|
import { devServerConfig } from "./dev-server-config";
|
||||||
|
import type { MiddlewareHandler } from "hono";
|
||||||
|
|
||||||
export type ViteEnv = NodeJS.ProcessEnv;
|
export type ViteEnv = NodeJS.ProcessEnv;
|
||||||
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
|
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {
|
||||||
|
serveStatic?: false | MiddlewareHandler;
|
||||||
|
};
|
||||||
|
|
||||||
export function addViteScript(
|
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
||||||
html: string,
|
|
||||||
addBkndContext: boolean = true,
|
|
||||||
) {
|
|
||||||
return html.replace(
|
return html.replace(
|
||||||
"</head>",
|
"</head>",
|
||||||
`<script type="module">
|
`<script type="module">
|
||||||
@@ -48,7 +41,10 @@ async function createApp<ViteEnv>(
|
|||||||
mainPath: "/src/main.tsx",
|
mainPath: "/src/main.tsx",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
serveStatic: config.serveStatic || [
|
||||||
|
"/assets/*",
|
||||||
|
serveStatic({ root: config.distPath ?? "./" }),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
env,
|
env,
|
||||||
opts,
|
opts,
|
||||||
|
|||||||
+20
-151
@@ -1,29 +1,24 @@
|
|||||||
import {
|
import { Authenticator, AuthPermissions, Role, type Strategy } from "auth";
|
||||||
type AuthAction,
|
|
||||||
AuthPermissions,
|
|
||||||
Authenticator,
|
|
||||||
type ProfileExchange,
|
|
||||||
Role,
|
|
||||||
type Strategy,
|
|
||||||
} from "auth";
|
|
||||||
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
import type { PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import { $console, type DB, Exception, type PrimaryFieldType } from "core";
|
import type { DB } from "core";
|
||||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
import { $console, secureRandomString, transformObject } from "core/utils";
|
||||||
import type { Entity, EntityManager } from "data";
|
import type { Entity, EntityManager } from "data";
|
||||||
import { type FieldSchema, em, entity, enumm, text } from "data/prototype";
|
import { em, entity, enumm, type FieldSchema } from "data/prototype";
|
||||||
import { pick } from "lodash-es";
|
|
||||||
import { Module } from "modules/Module";
|
import { Module } from "modules/Module";
|
||||||
import { AuthController } from "./api/AuthController";
|
import { AuthController } from "./api/AuthController";
|
||||||
import { type AppAuthSchema, STRATEGIES, authConfigSchema } from "./auth-schema";
|
import { type AppAuthSchema, authConfigSchema, STRATEGIES } from "./auth-schema";
|
||||||
|
import { AppUserPool } from "auth/AppUserPool";
|
||||||
|
import type { AppEntity } from "core/config";
|
||||||
|
import { usersFields } from "./auth-entities";
|
||||||
|
|
||||||
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
export type UserFieldSchema = FieldSchema<typeof AppAuth.usersFields>;
|
||||||
declare module "core" {
|
declare module "core" {
|
||||||
|
interface Users extends AppEntity, UserFieldSchema {}
|
||||||
interface DB {
|
interface DB {
|
||||||
users: { id: PrimaryFieldType } & UserFieldSchema;
|
users: Users;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthSchema = Static<typeof authConfigSchema>;
|
|
||||||
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
export type CreateUserPayload = { email: string; password: string; [key: string]: any };
|
||||||
|
|
||||||
export class AppAuth extends Module<typeof authConfigSchema> {
|
export class AppAuth extends Module<typeof authConfigSchema> {
|
||||||
@@ -31,12 +26,12 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
cache: Record<string, any> = {};
|
cache: Record<string, any> = {};
|
||||||
_controller!: AuthController;
|
_controller!: AuthController;
|
||||||
|
|
||||||
override async onBeforeUpdate(from: AuthSchema, to: AuthSchema) {
|
override async onBeforeUpdate(from: AppAuthSchema, to: AppAuthSchema) {
|
||||||
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
|
const defaultSecret = authConfigSchema.properties.jwt.properties.secret.default;
|
||||||
|
|
||||||
if (!from.enabled && to.enabled) {
|
if (!from.enabled && to.enabled) {
|
||||||
if (to.jwt.secret === defaultSecret) {
|
if (to.jwt.secret === defaultSecret) {
|
||||||
console.warn("No JWT secret provided, generating a random one");
|
$console.warn("No JWT secret provided, generating a random one");
|
||||||
to.jwt.secret = secureRandomString(64);
|
to.jwt.secret = secureRandomString(64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +75,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this._authenticator = new Authenticator(strategies, this.resolveUser.bind(this), {
|
this._authenticator = new Authenticator(strategies, new AppUserPool(this), {
|
||||||
jwt: this.config.jwt,
|
jwt: this.config.jwt,
|
||||||
cookie: this.config.cookie,
|
cookie: this.config.cookie,
|
||||||
});
|
});
|
||||||
@@ -90,7 +85,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
this._controller = new AuthController(this);
|
this._controller = new AuthController(this);
|
||||||
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
this.ctx.server.route(this.config.basepath, this._controller.getController());
|
||||||
this.ctx.guard.registerPermissions(Object.values(AuthPermissions));
|
this.ctx.guard.registerPermissions(AuthPermissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
isStrategyEnabled(strategy: Strategy | string) {
|
isStrategyEnabled(strategy: Strategy | string) {
|
||||||
@@ -122,120 +117,6 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this.ctx.em as any;
|
return this.ctx.em as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveUser(
|
|
||||||
action: AuthAction,
|
|
||||||
strategy: Strategy,
|
|
||||||
identifier: string,
|
|
||||||
profile: ProfileExchange,
|
|
||||||
): Promise<any> {
|
|
||||||
if (!this.config.allow_register && action === "register") {
|
|
||||||
throw new Exception("Registration is not allowed", 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = this.getUsersEntity()
|
|
||||||
.getFillableFields("create")
|
|
||||||
.map((f) => f.name);
|
|
||||||
const filteredProfile = Object.fromEntries(
|
|
||||||
Object.entries(profile).filter(([key]) => fields.includes(key)),
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case "login":
|
|
||||||
return this.login(strategy, identifier, filteredProfile);
|
|
||||||
case "register":
|
|
||||||
return this.register(strategy, identifier, filteredProfile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterUserData(user: any) {
|
|
||||||
return pick(user, this.config.jwt.fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async login(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
|
||||||
if (!("email" in profile)) {
|
|
||||||
throw new Exception("Profile must have email");
|
|
||||||
}
|
|
||||||
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
||||||
throw new Exception("Identifier must be a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = this.getUsersEntity();
|
|
||||||
this.toggleStrategyValueVisibility(true);
|
|
||||||
const result = await this.em
|
|
||||||
.repo(users as unknown as "users")
|
|
||||||
.findOne({ email: profile.email! });
|
|
||||||
this.toggleStrategyValueVisibility(false);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Exception("User not found", 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// compare strategy and identifier
|
|
||||||
if (result.data.strategy !== strategy.getName()) {
|
|
||||||
//console.log("!!! User registered with different strategy");
|
|
||||||
throw new Exception("User registered with different strategy");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data.strategy_value !== identifier) {
|
|
||||||
throw new Exception("Invalid credentials");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.filterUserData(result.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async register(strategy: Strategy, identifier: string, profile: ProfileExchange) {
|
|
||||||
if (!("email" in profile)) {
|
|
||||||
throw new Exception("Profile must have an email");
|
|
||||||
}
|
|
||||||
if (typeof identifier !== "string" || identifier.length === 0) {
|
|
||||||
throw new Exception("Identifier must be a string");
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = this.getUsersEntity();
|
|
||||||
const { data } = await this.em.repo(users).findOne({ email: profile.email! });
|
|
||||||
if (data) {
|
|
||||||
throw new Exception("User already exists");
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: any = {
|
|
||||||
...profile,
|
|
||||||
strategy: strategy.getName(),
|
|
||||||
strategy_value: identifier,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mutator = this.em.mutator(users);
|
|
||||||
mutator.__unstable_toggleSystemEntityCreation(false);
|
|
||||||
this.toggleStrategyValueVisibility(true);
|
|
||||||
const createResult = await mutator.insertOne(payload);
|
|
||||||
mutator.__unstable_toggleSystemEntityCreation(true);
|
|
||||||
this.toggleStrategyValueVisibility(false);
|
|
||||||
if (!createResult.data) {
|
|
||||||
throw new Error("Could not create user");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.filterUserData(createResult.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private toggleStrategyValueVisibility(visible: boolean) {
|
|
||||||
const toggle = (name: string, visible: boolean) => {
|
|
||||||
const field = this.getUsersEntity().field(name)!;
|
|
||||||
|
|
||||||
if (visible) {
|
|
||||||
field.config.hidden = false;
|
|
||||||
field.config.fillable = true;
|
|
||||||
} else {
|
|
||||||
// reset to normal
|
|
||||||
const template = AppAuth.usersFields.strategy_value.config;
|
|
||||||
field.config.hidden = template.hidden;
|
|
||||||
field.config.fillable = template.fillable;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
toggle("strategy_value", visible);
|
|
||||||
toggle("strategy", visible);
|
|
||||||
|
|
||||||
// @todo: think about a PasswordField that automatically hashes on save?
|
|
||||||
}
|
|
||||||
|
|
||||||
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
|
getUsersEntity(forceCreate?: boolean): Entity<"users", typeof AppAuth.usersFields> {
|
||||||
const entity_name = this.config.entity_name;
|
const entity_name = this.config.entity_name;
|
||||||
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
if (forceCreate || !this.em.hasEntity(entity_name)) {
|
||||||
@@ -245,22 +126,11 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
return this.em.entity(entity_name) as any;
|
return this.em.entity(entity_name) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
static usersFields = {
|
static usersFields = usersFields;
|
||||||
email: text().required(),
|
|
||||||
strategy: text({
|
|
||||||
fillable: ["create"],
|
|
||||||
hidden: ["update", "form"],
|
|
||||||
}).required(),
|
|
||||||
strategy_value: text({
|
|
||||||
fillable: ["create"],
|
|
||||||
hidden: ["read", "table", "update", "form"],
|
|
||||||
}).required(),
|
|
||||||
role: text(),
|
|
||||||
};
|
|
||||||
|
|
||||||
registerEntities() {
|
registerEntities() {
|
||||||
const users = this.getUsersEntity(true);
|
const users = this.getUsersEntity(true);
|
||||||
this.ensureSchema(
|
this.ctx.helper.ensureSchema(
|
||||||
em(
|
em(
|
||||||
{
|
{
|
||||||
[users.name as "users"]: users,
|
[users.name as "users"]: users,
|
||||||
@@ -273,13 +143,13 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const roles = Object.keys(this.config.roles ?? {});
|
const roles = Object.keys(this.config.roles ?? {});
|
||||||
this.replaceEntityField(users, "role", enumm({ enum: roles }));
|
this.ctx.helper.replaceEntityField(users, "role", enumm({ enum: roles }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// also keep disabled strategies as a choice
|
// also keep disabled strategies as a choice
|
||||||
const strategies = Object.keys(this.config.strategies ?? {});
|
const strategies = Object.keys(this.config.strategies ?? {});
|
||||||
this.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
this.ctx.helper.replaceEntityField(users, "strategy", enumm({ enum: strategies }));
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +158,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
throw new Error("Cannot create user, auth not enabled");
|
throw new Error("Cannot create user, auth not enabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
const strategy = "password";
|
const strategy = "password" as const;
|
||||||
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
const pw = this.authenticator.strategy(strategy) as PasswordStrategy;
|
||||||
const strategy_value = await pw.hash(password);
|
const strategy_value = await pw.hash(password);
|
||||||
const mutator = this.em.mutator(this.config.entity_name as "users");
|
const mutator = this.em.mutator(this.config.entity_name as "users");
|
||||||
@@ -315,8 +185,7 @@ export class AppAuth extends Module<typeof authConfigSchema> {
|
|||||||
...this.authenticator.toJSON(secrets),
|
...this.authenticator.toJSON(secrets),
|
||||||
strategies: transformObject(strategies, (strategy) => ({
|
strategies: transformObject(strategies, (strategy) => ({
|
||||||
enabled: this.isStrategyEnabled(strategy),
|
enabled: this.isStrategyEnabled(strategy),
|
||||||
type: strategy.getType(),
|
...strategy.toJSON(secrets),
|
||||||
config: strategy.toJSON(secrets),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { AppAuth } from "auth/AppAuth";
|
||||||
|
import type { CreateUser, SafeUser, User, UserPool } from "auth/authenticate/Authenticator";
|
||||||
|
import { $console } from "core/utils";
|
||||||
|
import { pick } from "lodash-es";
|
||||||
|
import {
|
||||||
|
InvalidConditionsException,
|
||||||
|
UnableToCreateUserException,
|
||||||
|
UserNotFoundException,
|
||||||
|
} from "auth/errors";
|
||||||
|
|
||||||
|
export class AppUserPool implements UserPool {
|
||||||
|
constructor(private appAuth: AppAuth) {}
|
||||||
|
|
||||||
|
get em() {
|
||||||
|
return this.appAuth.em;
|
||||||
|
}
|
||||||
|
|
||||||
|
get users() {
|
||||||
|
return this.appAuth.getUsersEntity();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBy(strategy: string, prop: keyof SafeUser, value: any) {
|
||||||
|
$console.debug("[AppUserPool:findBy]", { strategy, prop, value });
|
||||||
|
this.toggleStrategyValueVisibility(true);
|
||||||
|
const result = await this.em.repo(this.users).findOne({ [prop]: value, strategy });
|
||||||
|
this.toggleStrategyValueVisibility(false);
|
||||||
|
|
||||||
|
if (!result.data) {
|
||||||
|
$console.debug("[AppUserPool]: User not found");
|
||||||
|
throw new UserNotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(strategy: string, payload: CreateUser & Partial<Omit<User, "id">>) {
|
||||||
|
$console.debug("[AppUserPool:create]", { strategy, payload });
|
||||||
|
if (!("strategy_value" in payload)) {
|
||||||
|
throw new InvalidConditionsException("Profile must have a strategy_value value");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = this.users.getSelect(undefined, "create");
|
||||||
|
const safeProfile = pick(payload, fields) as any;
|
||||||
|
const createPayload: Omit<User, "id"> = {
|
||||||
|
...safeProfile,
|
||||||
|
strategy,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutator = this.em.mutator(this.users);
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(false);
|
||||||
|
this.toggleStrategyValueVisibility(true);
|
||||||
|
const createResult = await mutator.insertOne(createPayload);
|
||||||
|
mutator.__unstable_toggleSystemEntityCreation(true);
|
||||||
|
this.toggleStrategyValueVisibility(false);
|
||||||
|
if (!createResult.data) {
|
||||||
|
throw new UnableToCreateUserException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$console.debug("[AppUserPool]: User created", createResult.data);
|
||||||
|
return createResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleStrategyValueVisibility(visible: boolean) {
|
||||||
|
const toggle = (name: string, visible: boolean) => {
|
||||||
|
const field = this.users.field(name)!;
|
||||||
|
|
||||||
|
if (visible) {
|
||||||
|
field.config.hidden = false;
|
||||||
|
field.config.fillable = true;
|
||||||
|
} else {
|
||||||
|
// reset to normal
|
||||||
|
const template = AppAuth.usersFields.strategy_value.config;
|
||||||
|
field.config.hidden = template.hidden;
|
||||||
|
field.config.fillable = template.fillable;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toggle("strategy_value", visible);
|
||||||
|
toggle("strategy", visible);
|
||||||
|
|
||||||
|
// @todo: think about a PasswordField that automatically hashes on save?
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,21 @@ import type { AuthResponse, SafeUser, Strategy } from "auth/authenticate/Authent
|
|||||||
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
import { type BaseModuleApiOptions, ModuleApi } from "modules/ModuleApi";
|
||||||
|
|
||||||
export type AuthApiOptions = BaseModuleApiOptions & {
|
export type AuthApiOptions = BaseModuleApiOptions & {
|
||||||
onTokenUpdate?: (token: string) => void | Promise<void>;
|
onTokenUpdate?: (token?: string) => void | Promise<void>;
|
||||||
|
credentials?: "include" | "same-origin" | "omit";
|
||||||
};
|
};
|
||||||
|
|
||||||
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
export class AuthApi extends ModuleApi<AuthApiOptions> {
|
||||||
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
protected override getDefaultOptions(): Partial<AuthApiOptions> {
|
||||||
return {
|
return {
|
||||||
basepath: "/api/auth",
|
basepath: "/api/auth",
|
||||||
|
credentials: "include",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
credentials: this.options.credentials,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
@@ -27,7 +29,7 @@ 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",
|
credentials: this.options.credentials,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok && res.body.token) {
|
if (res.ok && res.body.token) {
|
||||||
@@ -68,5 +70,7 @@ export class AuthApi extends ModuleApi<AuthApiOptions> {
|
|||||||
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
return this.get<Pick<AppAuthSchema, "strategies" | "basepath">>(["strategies"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout() {}
|
async logout() {
|
||||||
|
await this.options.onTokenUpdate?.(undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
import { type AppAuth, AuthPermissions, type SafeUser, type Strategy } from "auth";
|
||||||
import { tbValidator as tb } from "core";
|
import { TypeInvalidError, parse, transformObject } from "core/utils";
|
||||||
import { Type, TypeInvalidError, parse, transformObject } from "core/utils";
|
|
||||||
import { DataPermissions } from "data";
|
import { DataPermissions } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import { Controller, type ServerEnv } from "modules/Controller";
|
import { Controller, type ServerEnv } from "modules/Controller";
|
||||||
|
import { describeRoute, jsc, s } from "core/object/schema";
|
||||||
|
|
||||||
export type AuthActionResponse = {
|
export type AuthActionResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -12,10 +12,6 @@ export type AuthActionResponse = {
|
|||||||
errors?: any;
|
errors?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
const booleanLike = Type.Transform(Type.String())
|
|
||||||
.Decode((v) => v === "1")
|
|
||||||
.Encode((v) => (v ? "1" : "0"));
|
|
||||||
|
|
||||||
export class AuthController extends Controller {
|
export class AuthController extends Controller {
|
||||||
constructor(private auth: AppAuth) {
|
constructor(private auth: AppAuth) {
|
||||||
super();
|
super();
|
||||||
@@ -54,6 +50,10 @@ export class AuthController extends Controller {
|
|||||||
hono.post(
|
hono.post(
|
||||||
"/create",
|
"/create",
|
||||||
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
permission([AuthPermissions.createUser, DataPermissions.entityCreate]),
|
||||||
|
describeRoute({
|
||||||
|
summary: "Create a new user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await this.auth.authenticator.getBody(c);
|
const body = await this.auth.authenticator.getBody(c);
|
||||||
@@ -91,9 +91,16 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
hono.get("create/schema.json", async (c) => {
|
hono.get(
|
||||||
|
"create/schema.json",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Get the schema for creating a user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
return c.json(create.schema);
|
return c.json(create.schema);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainHono.route(`/${name}/actions`, hono);
|
mainHono.route(`/${name}/actions`, hono);
|
||||||
@@ -102,26 +109,34 @@ export class AuthController extends Controller {
|
|||||||
override getController() {
|
override getController() {
|
||||||
const { auth } = this.middlewares;
|
const { auth } = this.middlewares;
|
||||||
const hono = this.create();
|
const hono = this.create();
|
||||||
const strategies = this.auth.authenticator.getStrategies();
|
|
||||||
|
|
||||||
for (const [name, strategy] of Object.entries(strategies)) {
|
hono.get(
|
||||||
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
"/me",
|
||||||
|
describeRoute({
|
||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
summary: "Get the current user",
|
||||||
this.registerStrategyActions(strategy, hono);
|
tags: ["auth"],
|
||||||
}
|
}),
|
||||||
|
auth(),
|
||||||
hono.get("/me", auth(), async (c) => {
|
async (c) => {
|
||||||
const claims = c.get("auth")?.user;
|
const claims = c.get("auth")?.user;
|
||||||
if (claims) {
|
if (claims) {
|
||||||
const { data: user } = await this.userRepo.findId(claims.id);
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
|
await this.auth.authenticator?.requestCookieRefresh(c);
|
||||||
return c.json({ user });
|
return c.json({ user });
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
return c.json({ user: null }, 403);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
hono.get("/logout", auth(), async (c) => {
|
hono.get(
|
||||||
|
"/logout",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Logout the current user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
await this.auth.authenticator.logout(c);
|
await this.auth.authenticator.logout(c);
|
||||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
@@ -133,11 +148,16 @@ export class AuthController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return c.redirect("/");
|
return c.redirect("/");
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/strategies",
|
"/strategies",
|
||||||
tb("query", Type.Object({ include_disabled: Type.Optional(booleanLike) })),
|
describeRoute({
|
||||||
|
summary: "Get the available authentication strategies",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
jsc("query", s.object({ include_disabled: s.boolean().optional() })),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { include_disabled } = c.req.valid("query");
|
const { include_disabled } = c.req.valid("query");
|
||||||
const { strategies, basepath } = this.auth.toJSON(false);
|
const { strategies, basepath } = this.auth.toJSON(false);
|
||||||
@@ -155,6 +175,15 @@ export class AuthController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return hono.all("*", (c) => c.notFound());
|
const strategies = this.auth.authenticator.getStrategies();
|
||||||
|
|
||||||
|
for (const [name, strategy] of Object.entries(strategies)) {
|
||||||
|
if (!this.auth.isStrategyEnabled(strategy)) continue;
|
||||||
|
|
||||||
|
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
||||||
|
this.registerStrategyActions(strategy, hono);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hono;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { text } from "data/prototype";
|
||||||
|
|
||||||
|
export const usersFields = {
|
||||||
|
email: text().required(),
|
||||||
|
strategy: text({
|
||||||
|
fillable: ["create"],
|
||||||
|
hidden: ["update", "form"],
|
||||||
|
}).required(),
|
||||||
|
strategy_value: text({
|
||||||
|
fillable: ["create"],
|
||||||
|
hidden: ["read", "table", "update", "form"],
|
||||||
|
}).required(),
|
||||||
|
role: text(),
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
|
||||||
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
|
||||||
import { type Static, StringRecord, Type, objectTransform } from "core/utils";
|
import { type Static, StringRecord, objectTransform } from "core/utils";
|
||||||
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
const { Type } = tbbox;
|
||||||
|
|
||||||
export const Strategies = {
|
export const Strategies = {
|
||||||
password: {
|
password: {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { type DB, Exception, type PrimaryFieldType } from "core";
|
import { type DB, Exception } from "core";
|
||||||
import { addFlashMessage } from "core/server/flash";
|
import { addFlashMessage } from "core/server/flash";
|
||||||
import {
|
import {
|
||||||
|
$console,
|
||||||
type Static,
|
type Static,
|
||||||
StringEnum,
|
StringEnum,
|
||||||
type TObject,
|
type TObject,
|
||||||
Type,
|
|
||||||
parse,
|
parse,
|
||||||
runtimeSupports,
|
runtimeSupports,
|
||||||
transformObject,
|
truncate,
|
||||||
} from "core/utils";
|
} from "core/utils";
|
||||||
import type { Context, Hono } from "hono";
|
import type { Context, Hono } from "hono";
|
||||||
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
@@ -15,6 +15,9 @@ import { sign, verify } from "hono/jwt";
|
|||||||
import type { CookieOptions } from "hono/utils/cookie";
|
import type { CookieOptions } from "hono/utils/cookie";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
import { pick } from "lodash-es";
|
import { pick } from "lodash-es";
|
||||||
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
import { InvalidConditionsException } from "auth/errors";
|
||||||
|
const { Type } = tbbox;
|
||||||
|
|
||||||
type Input = any; // workaround
|
type Input = any; // workaround
|
||||||
export type JWTPayload = Parameters<typeof sign>[0];
|
export type JWTPayload = Parameters<typeof sign>[0];
|
||||||
@@ -23,11 +26,12 @@ export const strategyActions = ["create", "change"] as const;
|
|||||||
export type StrategyActionName = (typeof strategyActions)[number];
|
export type StrategyActionName = (typeof strategyActions)[number];
|
||||||
export type StrategyAction<S extends TObject = TObject> = {
|
export type StrategyAction<S extends TObject = TObject> = {
|
||||||
schema: S;
|
schema: S;
|
||||||
preprocess: (input: unknown) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
preprocess: (input: Static<S>) => Promise<Omit<DB["users"], "id" | "strategy">>;
|
||||||
};
|
};
|
||||||
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
|
export type StrategyActions = Partial<Record<StrategyActionName, StrategyAction>>;
|
||||||
|
|
||||||
// @todo: add schema to interface to ensure proper inference
|
// @todo: add schema to interface to ensure proper inference
|
||||||
|
// @todo: add tests (e.g. invalid strategy_value)
|
||||||
export interface Strategy {
|
export interface Strategy {
|
||||||
getController: (auth: Authenticator) => Hono<any>;
|
getController: (auth: Authenticator) => Hono<any>;
|
||||||
getType: () => string;
|
getType: () => string;
|
||||||
@@ -37,28 +41,22 @@ export interface Strategy {
|
|||||||
getActions?: () => StrategyActions;
|
getActions?: () => StrategyActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = DB["users"];
|
||||||
id: PrimaryFieldType;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
role?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProfileExchange = {
|
export type ProfileExchange = {
|
||||||
email?: string;
|
email?: string;
|
||||||
username?: string;
|
strategy?: string;
|
||||||
sub?: string;
|
strategy_value?: string;
|
||||||
password?: string;
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SafeUser = Omit<User, "password">;
|
export type SafeUser = Omit<User, "strategy_value">;
|
||||||
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
export type CreateUser = Pick<User, "email"> & { [key: string]: any };
|
||||||
export type AuthResponse = { user: SafeUser; token: string };
|
export type AuthResponse = { user: SafeUser; token: string };
|
||||||
|
|
||||||
export interface UserPool<Fields = "id" | "email" | "username"> {
|
export interface UserPool {
|
||||||
findBy: (prop: Fields, value: string | number) => Promise<User | undefined>;
|
findBy: (strategy: string, prop: keyof SafeUser, value: string | number) => Promise<User>;
|
||||||
create: (user: CreateUser) => Promise<User | undefined>;
|
create: (strategy: string, user: CreateUser) => Promise<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
const defaultCookieExpires = 60 * 60 * 24 * 7; // 1 week in seconds
|
||||||
@@ -100,12 +98,17 @@ export const authenticatorConfig = Type.Object({
|
|||||||
|
|
||||||
type AuthConfig = Static<typeof authenticatorConfig>;
|
type AuthConfig = Static<typeof authenticatorConfig>;
|
||||||
export type AuthAction = "login" | "register";
|
export type AuthAction = "login" | "register";
|
||||||
|
export type AuthResolveOptions = {
|
||||||
|
identifier?: "email" | string;
|
||||||
|
redirect?: string;
|
||||||
|
forceJsonResponse?: boolean;
|
||||||
|
};
|
||||||
export type AuthUserResolver = (
|
export type AuthUserResolver = (
|
||||||
action: AuthAction,
|
action: AuthAction,
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
identifier: string,
|
|
||||||
profile: ProfileExchange,
|
profile: ProfileExchange,
|
||||||
) => Promise<SafeUser | undefined>;
|
opts?: AuthResolveOptions,
|
||||||
|
) => Promise<ProfileExchange | undefined>;
|
||||||
type AuthClaims = SafeUser & {
|
type AuthClaims = SafeUser & {
|
||||||
iat: number;
|
iat: number;
|
||||||
iss?: string;
|
iss?: string;
|
||||||
@@ -113,33 +116,117 @@ type AuthClaims = SafeUser & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 config: AuthConfig;
|
private readonly config: AuthConfig;
|
||||||
private readonly userResolver: AuthUserResolver;
|
|
||||||
|
|
||||||
constructor(strategies: Strategies, userResolver?: AuthUserResolver, config?: AuthConfig) {
|
constructor(
|
||||||
this.userResolver = userResolver ?? (async (a, s, i, p) => p as any);
|
private readonly strategies: Strategies,
|
||||||
this.strategies = strategies as Strategies;
|
private readonly userPool: UserPool,
|
||||||
|
config?: AuthConfig,
|
||||||
|
) {
|
||||||
this.config = parse(authenticatorConfig, config ?? {});
|
this.config = parse(authenticatorConfig, config ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(
|
async resolveLogin(
|
||||||
action: AuthAction,
|
c: Context,
|
||||||
strategy: Strategy,
|
strategy: Strategy,
|
||||||
identifier: string,
|
profile: Partial<SafeUser>,
|
||||||
profile: ProfileExchange,
|
verify: (user: User) => Promise<void>,
|
||||||
): Promise<AuthResponse> {
|
opts?: AuthResolveOptions,
|
||||||
//console.log("resolve", { action, strategy: strategy.getName(), profile });
|
) {
|
||||||
const user = await this.userResolver(action, strategy, identifier, profile);
|
try {
|
||||||
|
// @todo: centralize identifier and checks
|
||||||
if (user) {
|
// @todo: check identifier value (if allowed)
|
||||||
return {
|
const identifier = opts?.identifier || "email";
|
||||||
user,
|
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||||
token: await this.jwt(user),
|
throw new InvalidConditionsException("Identifier must be a string");
|
||||||
};
|
}
|
||||||
|
if (!(identifier in profile)) {
|
||||||
|
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("User could not be resolved");
|
const user = await this.userPool.findBy(
|
||||||
|
strategy.getName(),
|
||||||
|
identifier as any,
|
||||||
|
profile[identifier],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user.strategy_value) {
|
||||||
|
throw new InvalidConditionsException("User must have a strategy value");
|
||||||
|
} else if (user.strategy !== strategy.getName()) {
|
||||||
|
throw new InvalidConditionsException("User signed up with a different strategy");
|
||||||
|
}
|
||||||
|
|
||||||
|
await verify(user);
|
||||||
|
const data = await this.safeAuthResponse(user);
|
||||||
|
return this.respondWithUser(c, data, opts);
|
||||||
|
} catch (e) {
|
||||||
|
return this.respondWithError(c, e as Error, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveRegister(
|
||||||
|
c: Context,
|
||||||
|
strategy: Strategy,
|
||||||
|
profile: CreateUser,
|
||||||
|
verify: (user: User) => Promise<void>,
|
||||||
|
opts?: AuthResolveOptions,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const identifier = opts?.identifier || "email";
|
||||||
|
if (typeof identifier !== "string" || identifier.length === 0) {
|
||||||
|
throw new InvalidConditionsException("Identifier must be a string");
|
||||||
|
}
|
||||||
|
if (!(identifier in profile)) {
|
||||||
|
throw new InvalidConditionsException(`Profile must have identifier "${identifier}"`);
|
||||||
|
}
|
||||||
|
if (!("strategy_value" in profile)) {
|
||||||
|
throw new InvalidConditionsException("Profile must have a strategy value");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userPool.create(strategy.getName(), {
|
||||||
|
...profile,
|
||||||
|
strategy_value: profile.strategy_value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verify(user);
|
||||||
|
const data = await this.safeAuthResponse(user);
|
||||||
|
return this.respondWithUser(c, data, opts);
|
||||||
|
} catch (e) {
|
||||||
|
return this.respondWithError(c, e as Error, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async respondWithUser(c: Context, data: AuthResponse, opts?: AuthResolveOptions) {
|
||||||
|
const successUrl = this.getSafeUrl(
|
||||||
|
c,
|
||||||
|
opts?.redirect ?? this.config.cookie.pathSuccess ?? "/",
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("token" in data) {
|
||||||
|
await this.setAuthCookie(c, data.token);
|
||||||
|
|
||||||
|
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
|
||||||
|
return c.json(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// can't navigate to "/" – doesn't work on nextjs
|
||||||
|
return c.redirect(successUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("Invalid response");
|
||||||
|
}
|
||||||
|
|
||||||
|
async respondWithError(c: Context, error: Error, opts?: AuthResolveOptions) {
|
||||||
|
$console.error("respondWithError", error);
|
||||||
|
if (this.isJsonRequest(c) || opts?.forceJsonResponse) {
|
||||||
|
// let the server handle it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addFlashMessage(c, String(error), "error");
|
||||||
|
|
||||||
|
const referer = this.getSafeUrl(c, opts?.redirect ?? c.req.header("Referer") ?? "/");
|
||||||
|
return c.redirect(referer);
|
||||||
}
|
}
|
||||||
|
|
||||||
getStrategies(): Strategies {
|
getStrategies(): Strategies {
|
||||||
@@ -158,7 +245,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @todo: add jwt tests
|
// @todo: add jwt tests
|
||||||
async jwt(_user: Omit<User, "password">): Promise<string> {
|
async jwt(_user: SafeUser | ProfileExchange): Promise<string> {
|
||||||
const user = pick(_user, this.config.jwt.fields);
|
const user = pick(_user, this.config.jwt.fields);
|
||||||
|
|
||||||
const payload: JWTPayload = {
|
const payload: JWTPayload = {
|
||||||
@@ -184,6 +271,14 @@ 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 safeAuthResponse(_user: User): Promise<AuthResponse> {
|
||||||
|
const user = pick(_user, this.config.jwt.fields) as SafeUser;
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token: await this.jwt(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
async verify(jwt: string): Promise<AuthClaims | undefined> {
|
||||||
try {
|
try {
|
||||||
const payload = await verify(
|
const payload = await verify(
|
||||||
@@ -225,7 +320,7 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return token;
|
return token;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
console.error("[Error:getAuthCookie]", e.message);
|
$console.error("[getAuthCookie]", e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -242,27 +337,33 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
|
private async setAuthCookie(c: Context<ServerEnv>, token: string) {
|
||||||
|
$console.debug("setting auth cookie", truncate(token));
|
||||||
const secret = this.config.jwt.secret;
|
const secret = this.config.jwt.secret;
|
||||||
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
await setSignedCookie(c, "auth", token, secret, this.cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteAuthCookie(c: Context) {
|
private deleteAuthCookie(c: Context) {
|
||||||
await deleteCookie(c, "auth", this.cookieOptions);
|
$console.debug("deleting auth cookie");
|
||||||
|
deleteCookie(c, "auth", this.cookieOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(c: Context<ServerEnv>) {
|
async logout(c: Context<ServerEnv>) {
|
||||||
|
$console.info("Logging out");
|
||||||
c.set("auth", undefined);
|
c.set("auth", undefined);
|
||||||
|
|
||||||
const cookie = await this.getAuthCookie(c);
|
const cookie = await this.getAuthCookie(c);
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
await this.deleteAuthCookie(c);
|
addFlashMessage(c, "Signed out", "info");
|
||||||
await addFlashMessage(c, "Signed out", "info");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// on waku, only one cookie setting is performed
|
||||||
|
// therefore adding deleting cookie at the end
|
||||||
|
// as the flash isn't that important
|
||||||
|
this.deleteAuthCookie(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @todo: move this to a server helper
|
// @todo: move this to a server helper
|
||||||
isJsonRequest(c: Context): boolean {
|
isJsonRequest(c: Context): boolean {
|
||||||
//return c.req.header("Content-Type") === "application/x-www-form-urlencoded";
|
|
||||||
return c.req.header("Content-Type") === "application/json";
|
return c.req.header("Content-Type") === "application/json";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,37 +387,6 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
async respond(c: Context, data: AuthResponse | Error | any, redirect?: string) {
|
|
||||||
const successUrl = this.getSafeUrl(c, redirect ?? this.config.cookie.pathSuccess ?? "/");
|
|
||||||
const referer = redirect ?? c.req.header("Referer") ?? successUrl;
|
|
||||||
//console.log("auth respond", { redirect, successUrl, successPath });
|
|
||||||
|
|
||||||
if ("token" in data) {
|
|
||||||
await this.setAuthCookie(c, data.token);
|
|
||||||
|
|
||||||
if (this.isJsonRequest(c)) {
|
|
||||||
return c.json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// can't navigate to "/" – doesn't work on nextjs
|
|
||||||
//console.log("auth success, redirecting to", successUrl);
|
|
||||||
return c.redirect(successUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isJsonRequest(c)) {
|
|
||||||
return c.json(data, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = "An error occured";
|
|
||||||
if (data instanceof Exception) {
|
|
||||||
message = data.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
await addFlashMessage(c, message, "error");
|
|
||||||
//console.log("auth failed, redirecting to", referer);
|
|
||||||
return c.redirect(referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @todo: don't extract user from token, but from the database or cache
|
// @todo: don't extract user from token, but from the database or cache
|
||||||
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
|
async resolveAuthFromRequest(c: Context): Promise<SafeUser | undefined> {
|
||||||
let token: string | undefined;
|
let token: string | undefined;
|
||||||
@@ -341,13 +411,3 @@ export class Authenticator<Strategies extends Record<string, Strategy> = Record<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStrategyAction<S extends TObject>(
|
|
||||||
schema: S,
|
|
||||||
preprocess: (input: Static<S>) => Promise<Partial<DB["users"]>>,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
schema,
|
|
||||||
preprocess,
|
|
||||||
} as StrategyAction<S>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,152 +1,135 @@
|
|||||||
import type { Authenticator, Strategy } from "auth";
|
import { type Authenticator, InvalidCredentialsException, type User } from "auth";
|
||||||
import { isDebug, tbValidator as tb } from "core";
|
import { tbValidator as tb } from "core";
|
||||||
import { type Static, StringEnum, Type, parse } from "core/utils";
|
import { $console, hash, parse, type Static, StrictObject, StringEnum } from "core/utils";
|
||||||
import { hash } from "core/utils";
|
import { Hono } from "hono";
|
||||||
import { type Context, Hono } from "hono";
|
import { compare as bcryptCompare, genSalt as bcryptGenSalt, hash as bcryptHash } from "bcryptjs";
|
||||||
import { type StrategyAction, type StrategyActions, createStrategyAction } from "../Authenticator";
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
import { Strategy } from "./Strategy";
|
||||||
|
|
||||||
type LoginSchema = { username: string; password: string } | { email: string; password: string };
|
const { Type } = tbbox;
|
||||||
type RegisterSchema = { email: string; password: string; [key: string]: any };
|
|
||||||
|
|
||||||
const schema = Type.Object({
|
const schema = StrictObject({
|
||||||
hashing: StringEnum(["plain", "sha256" /*, "bcrypt"*/] as const, { default: "sha256" }),
|
hashing: StringEnum(["plain", "sha256", "bcrypt"], { default: "sha256" }),
|
||||||
|
rounds: Type.Optional(Type.Number({ minimum: 1, maximum: 10 })),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PasswordStrategyOptions = Static<typeof schema>;
|
export type PasswordStrategyOptions = Static<typeof schema>;
|
||||||
/*export type PasswordStrategyOptions2 = {
|
|
||||||
hashing?: "plain" | "bcrypt" | "sha256";
|
|
||||||
};*/
|
|
||||||
|
|
||||||
export class PasswordStrategy implements Strategy {
|
export class PasswordStrategy extends Strategy<typeof schema> {
|
||||||
private options: PasswordStrategyOptions;
|
constructor(config: Partial<PasswordStrategyOptions> = {}) {
|
||||||
|
super(config as any, "password", "password", "form");
|
||||||
|
|
||||||
constructor(options: Partial<PasswordStrategyOptions> = {}) {
|
this.registerAction("create", this.getPayloadSchema(), async ({ password, ...input }) => {
|
||||||
this.options = parse(schema, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
async hash(password: string) {
|
|
||||||
switch (this.options.hashing) {
|
|
||||||
case "sha256":
|
|
||||||
return hash.sha256(password);
|
|
||||||
default:
|
|
||||||
return password;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(input: LoginSchema) {
|
|
||||||
if (!("email" in input) || !("password" in input)) {
|
|
||||||
throw new Error("Invalid input: Email and password must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await this.hash(input.password);
|
|
||||||
return { ...input, password: hashedPassword };
|
|
||||||
}
|
|
||||||
|
|
||||||
async register(input: RegisterSchema) {
|
|
||||||
if (!input.email || !input.password) {
|
|
||||||
throw new Error("Invalid input: Email and password must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
password: await this.hash(input.password),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
getController(authenticator: Authenticator): Hono<any> {
|
|
||||||
const hono = new Hono();
|
|
||||||
|
|
||||||
return hono
|
|
||||||
.post(
|
|
||||||
"/login",
|
|
||||||
tb(
|
|
||||||
"query",
|
|
||||||
Type.Object({
|
|
||||||
redirect: Type.Optional(Type.String()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = await authenticator.getBody(c);
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload = await this.login(body);
|
|
||||||
const data = await authenticator.resolve(
|
|
||||||
"login",
|
|
||||||
this,
|
|
||||||
payload.password,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
return await authenticator.respond(c, data, redirect);
|
|
||||||
} catch (e) {
|
|
||||||
return await authenticator.respond(c, e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/register",
|
|
||||||
tb(
|
|
||||||
"query",
|
|
||||||
Type.Object({
|
|
||||||
redirect: Type.Optional(Type.String()),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const body = await authenticator.getBody(c);
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
|
|
||||||
const payload = await this.register(body);
|
|
||||||
const data = await authenticator.resolve(
|
|
||||||
"register",
|
|
||||||
this,
|
|
||||||
payload.password,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
|
|
||||||
return await authenticator.respond(c, data, redirect);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getActions(): StrategyActions {
|
|
||||||
return {
|
|
||||||
create: createStrategyAction(
|
|
||||||
Type.Object({
|
|
||||||
email: Type.String({
|
|
||||||
pattern: "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
|
||||||
}),
|
|
||||||
password: Type.String({
|
|
||||||
minLength: 8, // @todo: this should be configurable
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async ({ password, ...input }) => {
|
|
||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
strategy_value: await this.hash(password),
|
strategy_value: await this.hash(password),
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getSchema() {
|
getSchema() {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
getType() {
|
private getPayloadSchema() {
|
||||||
return "password";
|
return Type.Object({
|
||||||
|
email: Type.String({
|
||||||
|
pattern: "^[\\w-\\.\\+_]+@([\\w-]+\\.)+[\\w-]{2,4}$",
|
||||||
|
}),
|
||||||
|
password: Type.String({
|
||||||
|
minLength: 8, // @todo: this should be configurable
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getMode() {
|
async hash(password: string) {
|
||||||
return "form" as const;
|
switch (this.config.hashing) {
|
||||||
|
case "sha256":
|
||||||
|
return hash.sha256(password);
|
||||||
|
case "bcrypt": {
|
||||||
|
const salt = await bcryptGenSalt(this.config.rounds ?? 4);
|
||||||
|
return bcryptHash(password, salt);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return password;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getName() {
|
async compare(actual: string, compare: string): Promise<boolean> {
|
||||||
return "password" as const;
|
switch (this.config.hashing) {
|
||||||
|
case "sha256": {
|
||||||
|
const compareHashed = await this.hash(compare);
|
||||||
|
return actual === compareHashed;
|
||||||
|
}
|
||||||
|
case "bcrypt":
|
||||||
|
return await bcryptCompare(compare, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
return false;
|
||||||
return secrets ? this.options : undefined;
|
}
|
||||||
|
|
||||||
|
verify(password: string) {
|
||||||
|
return async (user: User) => {
|
||||||
|
const compare = await this.compare(user?.strategy_value!, password);
|
||||||
|
if (compare !== true) {
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getController(authenticator: Authenticator): Hono<any> {
|
||||||
|
const hono = new Hono();
|
||||||
|
const redirectQuerySchema = Type.Object({
|
||||||
|
redirect: Type.Optional(Type.String()),
|
||||||
|
});
|
||||||
|
const payloadSchema = this.getPayloadSchema();
|
||||||
|
|
||||||
|
hono.post("/login", tb("query", redirectQuerySchema), async (c) => {
|
||||||
|
try {
|
||||||
|
const body = parse(payloadSchema, await authenticator.getBody(c), {
|
||||||
|
onError: (errors) => {
|
||||||
|
$console.error("Invalid login payload", [...errors]);
|
||||||
|
throw new InvalidCredentialsException();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
|
||||||
|
return await authenticator.resolveLogin(c, this, body, this.verify(body.password), {
|
||||||
|
redirect,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return authenticator.respondWithError(c, e as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
hono.post("/register", tb("query", redirectQuerySchema), async (c) => {
|
||||||
|
try {
|
||||||
|
const { redirect } = c.req.valid("query");
|
||||||
|
const { password, email, ...body } = parse(
|
||||||
|
payloadSchema,
|
||||||
|
await authenticator.getBody(c),
|
||||||
|
{
|
||||||
|
onError: (errors) => {
|
||||||
|
$console.error("Invalid register payload", [...errors]);
|
||||||
|
new InvalidCredentialsException();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
...body,
|
||||||
|
email,
|
||||||
|
strategy_value: await this.hash(password),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await authenticator.resolveRegister(c, this, profile, async () => void 0, {
|
||||||
|
redirect,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return authenticator.respondWithError(c, e as any);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hono;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type {
|
||||||
|
Authenticator,
|
||||||
|
StrategyAction,
|
||||||
|
StrategyActionName,
|
||||||
|
StrategyActions,
|
||||||
|
} from "../Authenticator";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import type { Static, TSchema } from "@sinclair/typebox";
|
||||||
|
import { parse, type TObject } from "core/utils";
|
||||||
|
|
||||||
|
export type StrategyMode = "form" | "external";
|
||||||
|
|
||||||
|
export abstract class Strategy<Schema extends TSchema = TSchema> {
|
||||||
|
protected actions: StrategyActions = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected config: Static<Schema>,
|
||||||
|
public type: string,
|
||||||
|
public name: string,
|
||||||
|
public mode: StrategyMode,
|
||||||
|
) {
|
||||||
|
// don't worry about typing, it'll throw if invalid
|
||||||
|
this.config = parse(this.getSchema(), (config ?? {}) as any) as Static<Schema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected registerAction<S extends TObject = TObject>(
|
||||||
|
name: StrategyActionName,
|
||||||
|
schema: S,
|
||||||
|
preprocess: StrategyAction<S>["preprocess"],
|
||||||
|
): void {
|
||||||
|
this.actions[name] = {
|
||||||
|
schema,
|
||||||
|
preprocess,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract getSchema(): Schema;
|
||||||
|
|
||||||
|
abstract getController(auth: Authenticator): Hono;
|
||||||
|
|
||||||
|
getType(): string {
|
||||||
|
return this.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMode() {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(secrets?: boolean): { type: string; config: Static<Schema> | {} | undefined } {
|
||||||
|
return {
|
||||||
|
type: this.getType(),
|
||||||
|
config: secrets ? this.config : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getActions(): StrategyActions {
|
||||||
|
return this.actions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { OAuthCallbackException, OAuthStrategy } from "./oauth/OAuthStrategy";
|
|||||||
export * as issuers from "./oauth/issuers";
|
export * as issuers from "./oauth/issuers";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PasswordStrategy,
|
|
||||||
type PasswordStrategyOptions,
|
type PasswordStrategyOptions,
|
||||||
|
PasswordStrategy,
|
||||||
OAuthStrategy,
|
OAuthStrategy,
|
||||||
OAuthCallbackException,
|
OAuthCallbackException,
|
||||||
CustomOAuthStrategy,
|
CustomOAuthStrategy,
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
import { type Static, StringEnum, Type } from "core/utils";
|
import { type Static, StrictObject, StringEnum } from "core/utils";
|
||||||
|
import * as tbbox from "@sinclair/typebox";
|
||||||
import type * as oauth from "oauth4webapi";
|
import type * as oauth from "oauth4webapi";
|
||||||
import { OAuthStrategy } from "./OAuthStrategy";
|
import { OAuthStrategy } from "./OAuthStrategy";
|
||||||
|
const { Type } = tbbox;
|
||||||
|
|
||||||
type SupportedTypes = "oauth2" | "oidc";
|
type SupportedTypes = "oauth2" | "oidc";
|
||||||
|
|
||||||
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & Omit<T, K>;
|
||||||
|
|
||||||
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
|
const UrlString = Type.String({ pattern: "^(https?|wss?)://[^\\s/$.?#].[^\\s]*$" });
|
||||||
const oauthSchemaCustom = Type.Object(
|
const oauthSchemaCustom = StrictObject(
|
||||||
{
|
{
|
||||||
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
||||||
name: Type.String(),
|
name: Type.String(),
|
||||||
client: Type.Object(
|
client: StrictObject({
|
||||||
{
|
|
||||||
client_id: Type.String(),
|
client_id: Type.String(),
|
||||||
client_secret: Type.String(),
|
client_secret: Type.String(),
|
||||||
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
|
token_endpoint_auth_method: StringEnum(["client_secret_basic"]),
|
||||||
},
|
}),
|
||||||
{
|
as: StrictObject({
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
as: Type.Object(
|
|
||||||
{
|
|
||||||
issuer: Type.String(),
|
issuer: Type.String(),
|
||||||
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
|
code_challenge_methods_supported: Type.Optional(StringEnum(["S256"])),
|
||||||
scopes_supported: Type.Optional(Type.Array(Type.String())),
|
scopes_supported: Type.Optional(Type.Array(Type.String())),
|
||||||
@@ -30,14 +26,10 @@ const oauthSchemaCustom = Type.Object(
|
|||||||
authorization_endpoint: Type.Optional(UrlString),
|
authorization_endpoint: Type.Optional(UrlString),
|
||||||
token_endpoint: Type.Optional(UrlString),
|
token_endpoint: Type.Optional(UrlString),
|
||||||
userinfo_endpoint: Type.Optional(UrlString),
|
userinfo_endpoint: Type.Optional(UrlString),
|
||||||
},
|
}),
|
||||||
{
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// @todo: profile mapping
|
// @todo: profile mapping
|
||||||
},
|
},
|
||||||
{ title: "Custom OAuth", additionalProperties: false },
|
{ title: "Custom OAuth" },
|
||||||
);
|
);
|
||||||
|
|
||||||
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
type OAuthConfigCustom = Static<typeof oauthSchemaCustom>;
|
||||||
@@ -62,6 +54,11 @@ export type IssuerConfig<UserInfo = any> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class CustomOAuthStrategy extends OAuthStrategy {
|
export class CustomOAuthStrategy extends OAuthStrategy {
|
||||||
|
constructor(config: OAuthConfigCustom) {
|
||||||
|
super(config as any);
|
||||||
|
this.type = "custom_oauth";
|
||||||
|
}
|
||||||
|
|
||||||
override getIssuerConfig(): IssuerConfig {
|
override getIssuerConfig(): IssuerConfig {
|
||||||
return { ...this.config, profile: async (info) => info } as any;
|
return { ...this.config, profile: async (info) => info } as any;
|
||||||
}
|
}
|
||||||
@@ -70,8 +67,4 @@ export class CustomOAuthStrategy extends OAuthStrategy {
|
|||||||
override getSchema() {
|
override getSchema() {
|
||||||
return oauthSchemaCustom;
|
return oauthSchemaCustom;
|
||||||
}
|
}
|
||||||
|
|
||||||
override getType() {
|
|
||||||
return "custom_oauth";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import type { AuthAction, Authenticator, Strategy } from "auth";
|
import type { AuthAction, Authenticator } from "auth";
|
||||||
import { Exception, isDebug } from "core";
|
import { Exception, isDebug } from "core";
|
||||||
import { type Static, StringEnum, type TSchema, Type, filterKeys, parse } from "core/utils";
|
import { type Static, StringEnum, filterKeys, StrictObject } from "core/utils";
|
||||||
import { type Context, Hono } from "hono";
|
import { type Context, Hono } from "hono";
|
||||||
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
import { getSignedCookie, setSignedCookie } from "hono/cookie";
|
||||||
import * as oauth from "oauth4webapi";
|
import * as oauth from "oauth4webapi";
|
||||||
import * as issuers from "./issuers";
|
import * as issuers from "./issuers";
|
||||||
|
import * as tbbox from "@sinclair/typebox";
|
||||||
|
import { Strategy } from "auth/authenticate/strategies/Strategy";
|
||||||
|
const { Type } = tbbox;
|
||||||
|
|
||||||
type ConfiguredIssuers = keyof typeof issuers;
|
type ConfiguredIssuers = keyof typeof issuers;
|
||||||
type SupportedTypes = "oauth2" | "oidc";
|
type SupportedTypes = "oauth2" | "oidc";
|
||||||
@@ -13,17 +16,12 @@ type RequireKeys<T extends object, K extends keyof T> = Required<Pick<T, K>> & O
|
|||||||
|
|
||||||
const schemaProvided = Type.Object(
|
const schemaProvided = Type.Object(
|
||||||
{
|
{
|
||||||
//type: StringEnum(["oidc", "oauth2"] as const, { default: "oidc" }),
|
|
||||||
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
|
name: StringEnum(Object.keys(issuers) as ConfiguredIssuers[]),
|
||||||
client: Type.Object(
|
type: StringEnum(["oidc", "oauth2"] as const, { default: "oauth2" }),
|
||||||
{
|
client: StrictObject({
|
||||||
client_id: Type.String(),
|
client_id: Type.String(),
|
||||||
client_secret: Type.String(),
|
client_secret: Type.String(),
|
||||||
},
|
}),
|
||||||
{
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ title: "OAuth" },
|
{ title: "OAuth" },
|
||||||
);
|
);
|
||||||
@@ -71,11 +69,13 @@ export class OAuthCallbackException extends Exception {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OAuthStrategy implements Strategy {
|
export class OAuthStrategy extends Strategy<typeof schemaProvided> {
|
||||||
constructor(private _config: OAuthConfig) {}
|
constructor(config: ProvidedOAuthConfig) {
|
||||||
|
super(config, "oauth", config.name, "external");
|
||||||
|
}
|
||||||
|
|
||||||
get config() {
|
getSchema() {
|
||||||
return this._config;
|
return schemaProvided;
|
||||||
}
|
}
|
||||||
|
|
||||||
getIssuerConfig(): IssuerConfig {
|
getIssuerConfig(): IssuerConfig {
|
||||||
@@ -103,7 +103,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
type: info.type,
|
type: info.type,
|
||||||
client: {
|
client: {
|
||||||
...info.client,
|
...info.client,
|
||||||
...this._config.client,
|
...this.config.client,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -172,8 +172,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
) {
|
) {
|
||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
const { client, as, type } = config;
|
const { client, as, type } = config;
|
||||||
//console.log("config", config);
|
|
||||||
console.log("callbackParams", callbackParams, options);
|
|
||||||
const parameters = oauth.validateAuthResponse(
|
const parameters = oauth.validateAuthResponse(
|
||||||
as,
|
as,
|
||||||
client, // no client_secret required
|
client, // no client_secret required
|
||||||
@@ -181,13 +180,9 @@ export class OAuthStrategy implements Strategy {
|
|||||||
oauth.expectNoState,
|
oauth.expectNoState,
|
||||||
);
|
);
|
||||||
if (oauth.isOAuth2Error(parameters)) {
|
if (oauth.isOAuth2Error(parameters)) {
|
||||||
//console.log("callback.error", parameters);
|
|
||||||
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
||||||
}
|
}
|
||||||
/*console.log(
|
|
||||||
"callback.parameters",
|
|
||||||
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
|
|
||||||
);*/
|
|
||||||
const response = await oauth.authorizationCodeGrantRequest(
|
const response = await oauth.authorizationCodeGrantRequest(
|
||||||
as,
|
as,
|
||||||
client,
|
client,
|
||||||
@@ -195,13 +190,9 @@ export class OAuthStrategy implements Strategy {
|
|||||||
options.redirect_uri,
|
options.redirect_uri,
|
||||||
options.state,
|
options.state,
|
||||||
);
|
);
|
||||||
//console.log("callback.response", response);
|
|
||||||
|
|
||||||
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
||||||
if (challenges) {
|
if (challenges) {
|
||||||
for (const challenge of challenges) {
|
|
||||||
//console.log("callback.challenge", challenge);
|
|
||||||
}
|
|
||||||
// @todo: Handle www-authenticate challenges as needed
|
// @todo: Handle www-authenticate challenges as needed
|
||||||
throw new OAuthCallbackException(challenges, "www-authenticate");
|
throw new OAuthCallbackException(challenges, "www-authenticate");
|
||||||
}
|
}
|
||||||
@@ -216,20 +207,13 @@ export class OAuthStrategy implements Strategy {
|
|||||||
expectedNonce,
|
expectedNonce,
|
||||||
);
|
);
|
||||||
if (oauth.isOAuth2Error(result)) {
|
if (oauth.isOAuth2Error(result)) {
|
||||||
console.log("callback.error", result);
|
|
||||||
// @todo: Handle OAuth 2.0 response body error
|
// @todo: Handle OAuth 2.0 response body error
|
||||||
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
throw new OAuthCallbackException(result, "processAuthorizationCodeOpenIDResponse");
|
||||||
}
|
}
|
||||||
|
|
||||||
//console.log("callback.result", result);
|
|
||||||
|
|
||||||
const claims = oauth.getValidatedIdTokenClaims(result);
|
const claims = oauth.getValidatedIdTokenClaims(result);
|
||||||
//console.log("callback.IDTokenClaims", claims);
|
|
||||||
|
|
||||||
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
|
const infoRequest = await oauth.userInfoRequest(as, client, result.access_token!);
|
||||||
|
|
||||||
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
|
const resultUser = await oauth.processUserInfoResponse(as, client, claims.sub, infoRequest);
|
||||||
//console.log("callback.resultUser", resultUser);
|
|
||||||
|
|
||||||
return await config.profile(resultUser, config, claims); // @todo: check claims
|
return await config.profile(resultUser, config, claims); // @todo: check claims
|
||||||
}
|
}
|
||||||
@@ -240,8 +224,7 @@ export class OAuthStrategy implements Strategy {
|
|||||||
) {
|
) {
|
||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
const { client, type, as, profile } = config;
|
const { client, type, as, profile } = config;
|
||||||
console.log("config", { client, as, type });
|
|
||||||
console.log("callbackParams", callbackParams, options);
|
|
||||||
const parameters = oauth.validateAuthResponse(
|
const parameters = oauth.validateAuthResponse(
|
||||||
as,
|
as,
|
||||||
client, // no client_secret required
|
client, // no client_secret required
|
||||||
@@ -249,13 +232,9 @@ export class OAuthStrategy implements Strategy {
|
|||||||
oauth.expectNoState,
|
oauth.expectNoState,
|
||||||
);
|
);
|
||||||
if (oauth.isOAuth2Error(parameters)) {
|
if (oauth.isOAuth2Error(parameters)) {
|
||||||
console.log("callback.error", parameters);
|
|
||||||
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
throw new OAuthCallbackException(parameters, "validateAuthResponse");
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
"callback.parameters",
|
|
||||||
JSON.stringify(Object.fromEntries(parameters.entries()), null, 2),
|
|
||||||
);
|
|
||||||
const response = await oauth.authorizationCodeGrantRequest(
|
const response = await oauth.authorizationCodeGrantRequest(
|
||||||
as,
|
as,
|
||||||
client,
|
client,
|
||||||
@@ -266,9 +245,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
|
|
||||||
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
const challenges = oauth.parseWwwAuthenticateChallenges(response);
|
||||||
if (challenges) {
|
if (challenges) {
|
||||||
for (const challenge of challenges) {
|
|
||||||
//console.log("callback.challenge", challenge);
|
|
||||||
}
|
|
||||||
// @todo: Handle www-authenticate challenges as needed
|
// @todo: Handle www-authenticate challenges as needed
|
||||||
throw new OAuthCallbackException(challenges, "www-authenticate");
|
throw new OAuthCallbackException(challenges, "www-authenticate");
|
||||||
}
|
}
|
||||||
@@ -279,19 +255,15 @@ export class OAuthStrategy implements Strategy {
|
|||||||
try {
|
try {
|
||||||
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
|
result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
|
||||||
if (oauth.isOAuth2Error(result)) {
|
if (oauth.isOAuth2Error(result)) {
|
||||||
console.log("error", result);
|
|
||||||
throw new Error(); // Handle OAuth 2.0 response body error
|
throw new Error(); // Handle OAuth 2.0 response body error
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result = (await copy.json()) as any;
|
result = (await copy.json()) as any;
|
||||||
console.log("failed", result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
|
const res2 = await oauth.userInfoRequest(as, client, result.access_token!);
|
||||||
const user = await res2.json();
|
const user = await res2.json();
|
||||||
console.log("res2", res2, user);
|
|
||||||
|
|
||||||
console.log("result", result);
|
|
||||||
return await config.profile(user, config, result);
|
return await config.profile(user, config, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +273,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
): Promise<UserProfile> {
|
): Promise<UserProfile> {
|
||||||
const type = this.getIssuerConfig().type;
|
const type = this.getIssuerConfig().type;
|
||||||
|
|
||||||
console.log("type", type);
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "oidc":
|
case "oidc":
|
||||||
return await this.oidc(callbackParams, options);
|
return await this.oidc(callbackParams, options);
|
||||||
@@ -325,7 +296,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setState = async (c: Context, config: TState): Promise<void> => {
|
const setState = async (c: Context, config: TState): Promise<void> => {
|
||||||
console.log("--- setting state", config);
|
|
||||||
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
await setSignedCookie(c, cookie_name, JSON.stringify(config), secret, {
|
||||||
secure: true,
|
secure: true,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -356,7 +326,6 @@ export class OAuthStrategy implements Strategy {
|
|||||||
const params = new URLSearchParams(url.search);
|
const params = new URLSearchParams(url.search);
|
||||||
|
|
||||||
const state = await getState(c);
|
const state = await getState(c);
|
||||||
console.log("state", state);
|
|
||||||
|
|
||||||
// @todo: add config option to determine if state.action is allowed
|
// @todo: add config option to determine if state.action is allowed
|
||||||
const redirect_uri =
|
const redirect_uri =
|
||||||
@@ -369,21 +338,28 @@ export class OAuthStrategy implements Strategy {
|
|||||||
state: state.state,
|
state: state.state,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
const safeProfile = {
|
||||||
const data = await auth.resolve(state.action, this, profile.sub, profile);
|
email: profile.email,
|
||||||
console.log("******** RESOLVED ********", data);
|
strategy_value: profile.sub,
|
||||||
|
} as const;
|
||||||
|
|
||||||
if (state.mode === "cookie") {
|
const verify = async (user) => {
|
||||||
return await auth.respond(c, data, state.redirect);
|
if (user.strategy_value !== profile.sub) {
|
||||||
|
throw new Exception("Invalid credentials");
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
const opts = {
|
||||||
|
redirect: state.redirect,
|
||||||
|
forceJsonResponse: state.mode !== "cookie",
|
||||||
|
} as const;
|
||||||
|
|
||||||
return c.json(data);
|
switch (state.action) {
|
||||||
} catch (e) {
|
case "login":
|
||||||
if (state.mode === "cookie") {
|
return auth.resolveLogin(c, this, safeProfile, verify, opts);
|
||||||
return await auth.respond(c, e, state.redirect);
|
case "register":
|
||||||
}
|
return auth.resolveRegister(c, this, safeProfile, verify, opts);
|
||||||
|
default:
|
||||||
throw e;
|
throw new Error("Invalid action");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -412,10 +388,8 @@ export class OAuthStrategy implements Strategy {
|
|||||||
redirect_uri,
|
redirect_uri,
|
||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
//console.log("_state", state);
|
|
||||||
|
|
||||||
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
|
await setState(c, { state, action, redirect: referer.toString(), mode: "cookie" });
|
||||||
console.log("--redirecting to", response.url);
|
|
||||||
|
|
||||||
return c.redirect(response.url);
|
return c.redirect(response.url);
|
||||||
});
|
});
|
||||||
@@ -456,28 +430,15 @@ export class OAuthStrategy implements Strategy {
|
|||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|
||||||
getType() {
|
override toJSON(secrets?: boolean) {
|
||||||
return "oauth";
|
|
||||||
}
|
|
||||||
|
|
||||||
getMode() {
|
|
||||||
return "external" as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
getName() {
|
|
||||||
return this.config.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSchema() {
|
|
||||||
return schemaProvided;
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
|
||||||
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
|
const config = secrets ? this.config : filterKeys(this.config, ["secret", "client_id"]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: this.getIssuerConfig().type,
|
...super.toJSON(secrets),
|
||||||
|
config: {
|
||||||
...config,
|
...config,
|
||||||
|
type: this.getIssuerConfig().type,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
|
|||||||
config: Omit<IssuerConfig, "profile">,
|
config: Omit<IssuerConfig, "profile">,
|
||||||
tokenResponse: any,
|
tokenResponse: any,
|
||||||
) => {
|
) => {
|
||||||
console.log("github info", info, config, tokenResponse);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("https://api.github.com/user/emails", {
|
const res = await fetch("https://api.github.com/user/emails", {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -45,7 +43,6 @@ export const github: IssuerConfig<GithubUserInfo> = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as GithubUserEmailResponse;
|
const data = (await res.json()) as GithubUserEmailResponse;
|
||||||
console.log("data", data);
|
|
||||||
const email = data.find((e: any) => e.primary)?.email;
|
const email = data.find((e: any) => e.primary)?.email;
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new Error("No primary email found");
|
throw new Error("No primary email found");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Exception, Permission } from "core";
|
import { Exception, Permission } from "core";
|
||||||
import { objectTransform } from "core/utils";
|
import { $console, objectTransform } from "core/utils";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
import { Role } from "./Role";
|
import { Role } from "./Role";
|
||||||
@@ -14,8 +14,6 @@ export type GuardConfig = {
|
|||||||
};
|
};
|
||||||
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
export type GuardContext = Context<ServerEnv> | GuardUserContext;
|
||||||
|
|
||||||
const debug = false;
|
|
||||||
|
|
||||||
export class Guard {
|
export class Guard {
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
roles?: Role[];
|
roles?: Role[];
|
||||||
@@ -83,8 +81,12 @@ export class Guard {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPermissions(permissions: Permission[]) {
|
registerPermissions(permissions: Record<string, Permission>);
|
||||||
for (const permission of permissions) {
|
registerPermissions(permissions: Permission[]);
|
||||||
|
registerPermissions(permissions: Permission[] | Record<string, Permission>) {
|
||||||
|
const p = Array.isArray(permissions) ? permissions : Object.values(permissions);
|
||||||
|
|
||||||
|
for (const permission of p) {
|
||||||
this.registerPermission(permission);
|
this.registerPermission(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,15 +97,13 @@ export class Guard {
|
|||||||
if (user && typeof user.role === "string") {
|
if (user && typeof user.role === "string") {
|
||||||
const role = this.roles?.find((role) => role.name === user?.role);
|
const role = this.roles?.find((role) => role.name === user?.role);
|
||||||
if (role) {
|
if (role) {
|
||||||
debug && console.log("guard: role found", [user.role]);
|
$console.debug(`guard: role "${user.role}" found`);
|
||||||
return role;
|
return role;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug &&
|
$console.debug("guard: role not found", {
|
||||||
console.log("guard: role not found", {
|
user,
|
||||||
user: user,
|
|
||||||
role: user?.role,
|
|
||||||
});
|
});
|
||||||
return this.getDefaultRole();
|
return this.getDefaultRole();
|
||||||
}
|
}
|
||||||
@@ -120,11 +120,14 @@ export class Guard {
|
|||||||
hasPermission(name: string, user?: GuardUserContext): boolean;
|
hasPermission(name: string, user?: GuardUserContext): boolean;
|
||||||
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
hasPermission(permissionOrName: Permission | string, user?: GuardUserContext): boolean {
|
||||||
if (!this.isEnabled()) {
|
if (!this.isEnabled()) {
|
||||||
//console.log("guard not enabled, allowing");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
const name = typeof permissionOrName === "string" ? permissionOrName : permissionOrName.name;
|
||||||
|
$console.debug("guard: checking permission", {
|
||||||
|
name,
|
||||||
|
user: { id: user?.id, role: user?.role },
|
||||||
|
});
|
||||||
const exists = this.permissionExists(name);
|
const exists = this.permissionExists(name);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
throw new Error(`Permission ${name} does not exist`);
|
throw new Error(`Permission ${name} does not exist`);
|
||||||
@@ -133,10 +136,10 @@ export class Guard {
|
|||||||
const role = this.getUserRole(user);
|
const role = this.getUserRole(user);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
debug && console.log("guard: role not found, denying");
|
$console.debug("guard: user has no role, denying");
|
||||||
return false;
|
return false;
|
||||||
} else if (role.implicit_allow === true) {
|
} else if (role.implicit_allow === true) {
|
||||||
debug && console.log("guard: role implicit allow, allowing");
|
$console.debug(`guard: role "${role.name}" has implicit allow, allowing`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +147,7 @@ export class Guard {
|
|||||||
(rolePermission) => rolePermission.permission.name === name,
|
(rolePermission) => rolePermission.permission.name === name,
|
||||||
);
|
);
|
||||||
|
|
||||||
debug &&
|
$console.debug("guard: rolePermission, allowing?", {
|
||||||
console.log("guard: rolePermission, allowing?", {
|
|
||||||
permission: name,
|
permission: name,
|
||||||
role: role.name,
|
role: role.name,
|
||||||
allowing: !!rolePermission,
|
allowing: !!rolePermission,
|
||||||
|
|||||||
+45
-7
@@ -1,28 +1,66 @@
|
|||||||
import { Exception } from "core";
|
import { Exception, isDebug } from "core";
|
||||||
|
import { HttpStatus } from "core/utils";
|
||||||
|
|
||||||
export class UserExistsException extends Exception {
|
export class AuthException extends Exception {
|
||||||
|
getSafeErrorAndCode() {
|
||||||
|
return {
|
||||||
|
error: "Invalid credentials",
|
||||||
|
code: HttpStatus.UNAUTHORIZED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override toJSON(): any {
|
||||||
|
if (isDebug()) {
|
||||||
|
return super.toJSON();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: this.getSafeErrorAndCode().error,
|
||||||
|
type: "AuthException",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserExistsException extends AuthException {
|
||||||
override name = "UserExistsException";
|
override name = "UserExistsException";
|
||||||
override code = 422;
|
override code = HttpStatus.UNPROCESSABLE_ENTITY;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("User already exists");
|
super("User already exists");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UserNotFoundException extends Exception {
|
export class UserNotFoundException extends AuthException {
|
||||||
override name = "UserNotFoundException";
|
override name = "UserNotFoundException";
|
||||||
override code = 404;
|
override code = HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("User not found");
|
super("User not found");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvalidCredentialsException extends Exception {
|
export class InvalidCredentialsException extends AuthException {
|
||||||
override name = "InvalidCredentialsException";
|
override name = "InvalidCredentialsException";
|
||||||
override code = 401;
|
override code = HttpStatus.UNAUTHORIZED;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super("Invalid credentials");
|
super("Invalid credentials");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UnableToCreateUserException extends AuthException {
|
||||||
|
override name = "UnableToCreateUserException";
|
||||||
|
override code = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super("Unable to create user");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidConditionsException extends AuthException {
|
||||||
|
override code = HttpStatus.UNPROCESSABLE_ENTITY;
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message ?? "Invalid conditions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
|
export { UserExistsException, UserNotFoundException, InvalidCredentialsException } from "./errors";
|
||||||
export { sha256 } from "./utils/hash";
|
|
||||||
export {
|
export {
|
||||||
type ProfileExchange,
|
type ProfileExchange,
|
||||||
type Strategy,
|
type Strategy,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user