mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 08:06:00 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e77982996 | |||
| 7b128c9701 | |||
| 061181d59d | |||
| af6cb0c8f0 | |||
| 5a693c0370 | |||
| 262588decc | |||
| 17ab35e245 | |||
| db795ec050 | |||
| 773df544dd | |||
| 0ac7d1fd6e | |||
| 6694c63990 | |||
| b3f95f9552 | |||
| 372f94d22a | |||
| d6f94a2ce1 | |||
| 89a39a7dc6 | |||
| 88cf65f792 | |||
| 07723ce6ae | |||
| 9010401af6 | |||
| 4c11789ea8 | |||
| 2988e4c3bd | |||
| ad7926db4c | |||
| 53a3dcdee7 | |||
| 53467d6750 | |||
| a80a731498 | |||
| 7e1757b7f4 | |||
| 2c29e06fb8 | |||
| ca86fa58ac | |||
| de984fa101 | |||
| a12d4e13d0 | |||
| fa6c7acaf5 | |||
| 0b41aa5a2d | |||
| a5ec40c517 | |||
| 75e2b96344 | |||
| aaae8d9681 | |||
| e4608b7df7 | |||
| 44b3f72005 | |||
| 9134d121cd | |||
| 2f067451b4 | |||
| 3f26c45dd9 | |||
| 36e4224b33 | |||
| 434d56672c | |||
| b2fd907e8c | |||
| b29c04e8c9 | |||
| 11a28eba88 | |||
| 9e3c081e50 | |||
| 40c9ef9d90 | |||
| 7facef47da | |||
| e2bf6a0724 | |||
| f6a511d998 | |||
| 9407f3d212 | |||
| 0424c08a9e | |||
| 9d122da4b9 | |||
| 0f56d554b0 | |||
| 06e92d268b |
@@ -15,12 +15,20 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
bun-version: "1.2.14"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
- name: Run tests
|
- name: Build
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: bun run test
|
run: bun run build:ci
|
||||||
|
|
||||||
|
- name: Run Bun tests
|
||||||
|
working-directory: ./app
|
||||||
|
run: bun run test:bun
|
||||||
|
|
||||||
|
- name: Run Node tests
|
||||||
|
working-directory: ./app
|
||||||
|
run: npm run test:node
|
||||||
+3
-1
@@ -29,4 +29,6 @@ packages/media/.env
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.git_old
|
.git_old
|
||||||
docker/tmp
|
docker/tmp
|
||||||
|
.debug
|
||||||
|
.history
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
bknd.config.*
|
||||||
|
__test__/helper.d.ts
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, describe, it, beforeAll, afterAll } from "bun:test";
|
||||||
|
import * as adapter from "adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("adapter", () => {
|
||||||
|
it("makes config", () => {
|
||||||
|
expect(adapter.makeConfig({})).toEqual({});
|
||||||
|
expect(adapter.makeConfig({}, { env: { TEST: "test" } })).toEqual({});
|
||||||
|
|
||||||
|
// merges everything returned from `app` with the config
|
||||||
|
expect(adapter.makeConfig({ app: (a) => a as any }, { env: { TEST: "test" } })).toEqual({
|
||||||
|
env: { TEST: "test" },
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses apps correctly", async () => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
const first = await adapter.createAdapterApp(
|
||||||
|
{
|
||||||
|
initialConfig: { server: { cors: { origin: "random" } } },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
const second = await adapter.createAdapterApp();
|
||||||
|
const third = await adapter.createAdapterApp(undefined, undefined, { id });
|
||||||
|
|
||||||
|
await first.build();
|
||||||
|
await second.build();
|
||||||
|
await third.build();
|
||||||
|
|
||||||
|
expect(first.toJSON().server.cors.origin).toEqual("random");
|
||||||
|
expect(first).toBe(third);
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
expect(second).not.toBe(third);
|
||||||
|
expect(second.toJSON().server.cors.origin).toEqual("*");
|
||||||
|
|
||||||
|
// recreate the first one
|
||||||
|
const first2 = await adapter.createAdapterApp(undefined, undefined, { id, force: true });
|
||||||
|
await first2.build();
|
||||||
|
expect(first2).not.toBe(first);
|
||||||
|
expect(first2).not.toBe(third);
|
||||||
|
expect(first2).not.toBe(second);
|
||||||
|
expect(first2.toJSON().server.cors.origin).toEqual("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: adapter.createFrameworkApp,
|
||||||
|
label: "framework app",
|
||||||
|
});
|
||||||
|
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: adapter.createRuntimeApp,
|
||||||
|
label: "runtime app",
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,8 @@ import { DataApi } from "../../src/data/api/DataApi";
|
|||||||
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 * as proto from "../../src/data/prototype";
|
import * as proto from "../../src/data/prototype";
|
||||||
import { disableConsoleLog, enableConsoleLog, schemaToEm } from "../helper";
|
import { schemaToEm } from "../helper";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
@@ -64,6 +65,15 @@ describe("DataApi", () => {
|
|||||||
const res = await req;
|
const res = await req;
|
||||||
expect(res.data).toEqual(payload as any);
|
expect(res.data).toEqual(payload as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
// make sure sort is working
|
||||||
|
const req = await api.readMany("posts", {
|
||||||
|
select: ["title"],
|
||||||
|
sort: "-id",
|
||||||
|
});
|
||||||
|
expect(req.data).toEqual(payload.reverse() as any);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates many", async () => {
|
it("updates many", 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 { type App, createApp } from "../../src/App";
|
import { App, createApp } from "../../src/App";
|
||||||
import * as proto from "../../src/data/prototype";
|
import * as proto from "../../src/data/prototype";
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
@@ -51,4 +51,87 @@ describe("App", () => {
|
|||||||
expect(todos[0]?.title).toBe("ctx");
|
expect(todos[0]?.title).toBe("ctx");
|
||||||
expect(todos[1]?.title).toBe("api");
|
expect(todos[1]?.title).toBe("api");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lifecycle events are triggered", async () => {
|
||||||
|
const firstBoot = mock(() => null);
|
||||||
|
const configUpdate = mock(() => null);
|
||||||
|
const appBuilt = mock(() => null);
|
||||||
|
const appRequest = mock(() => null);
|
||||||
|
const beforeResponse = mock(() => null);
|
||||||
|
|
||||||
|
const app = createApp();
|
||||||
|
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppFirstBoot,
|
||||||
|
(event) => {
|
||||||
|
expect(event).toBeInstanceOf(App.Events.AppFirstBoot);
|
||||||
|
expect(event.params.app.version()).toBe(app.version());
|
||||||
|
firstBoot();
|
||||||
|
},
|
||||||
|
"sync",
|
||||||
|
);
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppBuiltEvent,
|
||||||
|
(event) => {
|
||||||
|
expect(event).toBeInstanceOf(App.Events.AppBuiltEvent);
|
||||||
|
expect(event.params.app.version()).toBe(app.version());
|
||||||
|
appBuilt();
|
||||||
|
},
|
||||||
|
"sync",
|
||||||
|
);
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppConfigUpdatedEvent,
|
||||||
|
() => {
|
||||||
|
configUpdate();
|
||||||
|
},
|
||||||
|
"sync",
|
||||||
|
);
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppRequest,
|
||||||
|
(event) => {
|
||||||
|
expect(event).toBeInstanceOf(App.Events.AppRequest);
|
||||||
|
expect(event.params.app.version()).toBe(app.version());
|
||||||
|
expect(event.params.request).toBeInstanceOf(Request);
|
||||||
|
appRequest();
|
||||||
|
},
|
||||||
|
"sync",
|
||||||
|
);
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppBeforeResponse,
|
||||||
|
(event) => {
|
||||||
|
expect(event).toBeInstanceOf(App.Events.AppBeforeResponse);
|
||||||
|
expect(event.params.app.version()).toBe(app.version());
|
||||||
|
expect(event.params.response).toBeInstanceOf(Response);
|
||||||
|
beforeResponse();
|
||||||
|
},
|
||||||
|
"sync",
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
expect(firstBoot).toHaveBeenCalled();
|
||||||
|
expect(appBuilt).toHaveBeenCalled();
|
||||||
|
//expect(configUpdate).toHaveBeenCalled();
|
||||||
|
expect(appRequest).not.toHaveBeenCalled();
|
||||||
|
expect(beforeResponse).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emgr exec modes", async () => {
|
||||||
|
const called = mock(() => null);
|
||||||
|
const app = createApp({
|
||||||
|
options: {
|
||||||
|
asyncEventsMode: "sync",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// register async listener
|
||||||
|
app.emgr.onEvent(App.Events.AppFirstBoot, async () => {
|
||||||
|
called();
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.build();
|
||||||
|
await app.server.request(new Request("http://localhost"));
|
||||||
|
|
||||||
|
// expect async listeners to be executed sync after request
|
||||||
|
expect(called).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createApp, registries } from "../../src";
|
import { createApp, registries } from "../../src";
|
||||||
import * as proto from "../../src/data/prototype";
|
import * as proto from "../../src/data/prototype";
|
||||||
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
|
|
||||||
describe("repros", async () => {
|
describe("repros", async () => {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { OAuthStrategy } from "../../../src/auth/authenticate/strategies";
|
|||||||
|
|
||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||||
|
|
||||||
|
// @todo: add mock response
|
||||||
describe("OAuthStrategy", async () => {
|
describe("OAuthStrategy", async () => {
|
||||||
const strategy = new OAuthStrategy({
|
return;
|
||||||
|
/*const strategy = new OAuthStrategy({
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
client: {
|
client: {
|
||||||
client_id: process.env.OAUTH_CLIENT_ID!,
|
client_id: process.env.OAUTH_CLIENT_ID!,
|
||||||
@@ -21,6 +23,7 @@ describe("OAuthStrategy", async () => {
|
|||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
fetch: async (req) => {
|
fetch: async (req) => {
|
||||||
|
console.log("req", req.method, req.url);
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
if (url.pathname === "/auth/google/callback") {
|
if (url.pathname === "/auth/google/callback") {
|
||||||
console.log("req", req);
|
console.log("req", req);
|
||||||
@@ -42,5 +45,5 @@ describe("OAuthStrategy", async () => {
|
|||||||
console.log("request", request);
|
console.log("request", request);
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100000));
|
await new Promise((resolve) => setTimeout(resolve, 100000));
|
||||||
});
|
});*/
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ describe("EventManager", async () => {
|
|||||||
new SpecialEvent({ foo: "bar" });
|
new SpecialEvent({ foo: "bar" });
|
||||||
new InformationalEvent();
|
new InformationalEvent();
|
||||||
|
|
||||||
|
// execute asyncs
|
||||||
|
await emgr.executeAsyncs();
|
||||||
|
|
||||||
expect(call).toHaveBeenCalledTimes(2);
|
expect(call).toHaveBeenCalledTimes(2);
|
||||||
expect(delayed).toHaveBeenCalled();
|
expect(delayed).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -80,15 +83,11 @@ describe("EventManager", async () => {
|
|||||||
call();
|
call();
|
||||||
return Promise.all(p);
|
return Promise.all(p);
|
||||||
};
|
};
|
||||||
const emgr = new EventManager(
|
const emgr = new EventManager({ InformationalEvent });
|
||||||
{ InformationalEvent },
|
|
||||||
{
|
|
||||||
asyncExecutor,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
emgr.onEvent(InformationalEvent, async () => {});
|
emgr.onEvent(InformationalEvent, async () => {});
|
||||||
await emgr.emit(new InformationalEvent());
|
await emgr.emit(new InformationalEvent());
|
||||||
|
await emgr.executeAsyncs(asyncExecutor);
|
||||||
expect(call).toHaveBeenCalled();
|
expect(call).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,6 +124,9 @@ describe("EventManager", async () => {
|
|||||||
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
|
const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" }));
|
||||||
expect(e2.returned).toBe(true);
|
expect(e2.returned).toBe(true);
|
||||||
expect(e2.params.foo).toBe("bar-1-0");
|
expect(e2.params.foo).toBe("bar-1-0");
|
||||||
|
|
||||||
|
await emgr.executeAsyncs();
|
||||||
|
|
||||||
expect(onInvalidReturn).toHaveBeenCalled();
|
expect(onInvalidReturn).toHaveBeenCalled();
|
||||||
expect(asyncEventCallback).toHaveBeenCalled();
|
expect(asyncEventCallback).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,57 +0,0 @@
|
|||||||
import * as assert from "node:assert/strict";
|
|
||||||
import { createWriteStream } from "node:fs";
|
|
||||||
import { after, beforeEach, describe, test } from "node:test";
|
|
||||||
import { Miniflare } from "miniflare";
|
|
||||||
import {
|
|
||||||
CloudflareKVCacheItem,
|
|
||||||
CloudflareKVCachePool,
|
|
||||||
} from "../../../src/core/cache/adapters/CloudflareKvCache";
|
|
||||||
import { runTests } from "./cache-test-suite";
|
|
||||||
|
|
||||||
// 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`);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("CloudflareKv", async () => {
|
|
||||||
let mf: Miniflare;
|
|
||||||
runTests({
|
|
||||||
createCache: async () => {
|
|
||||||
if (mf) {
|
|
||||||
await mf.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
mf = new Miniflare({
|
|
||||||
modules: true,
|
|
||||||
script: "export default { async fetch() { return new Response(null); } }",
|
|
||||||
kvNamespaces: ["TEST"],
|
|
||||||
});
|
|
||||||
const kv = await mf.getKVNamespace("TEST");
|
|
||||||
return new CloudflareKVCachePool(kv as any);
|
|
||||||
},
|
|
||||||
createItem: (key, value) => new CloudflareKVCacheItem(key, value),
|
|
||||||
tester: {
|
|
||||||
test,
|
|
||||||
beforeEach,
|
|
||||||
expect: (actual?: any) => {
|
|
||||||
return {
|
|
||||||
toBe(expected: any) {
|
|
||||||
assert.equal(actual, expected);
|
|
||||||
},
|
|
||||||
toEqual(expected: any) {
|
|
||||||
assert.deepEqual(actual, expected);
|
|
||||||
},
|
|
||||||
toBeUndefined() {
|
|
||||||
assert.equal(actual, undefined);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
await mf?.dispose();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test } from "bun:test";
|
|
||||||
import { MemoryCache, MemoryCacheItem } from "../../../src/core/cache/adapters/MemoryCache";
|
|
||||||
import { runTests } from "./cache-test-suite";
|
|
||||||
|
|
||||||
describe("MemoryCache", () => {
|
|
||||||
runTests({
|
|
||||||
createCache: async () => new MemoryCache(),
|
|
||||||
createItem: (key, value) => new MemoryCacheItem(key, value),
|
|
||||||
tester: {
|
|
||||||
test,
|
|
||||||
beforeEach,
|
|
||||||
expect,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-84
@@ -1,84 +0,0 @@
|
|||||||
//import { beforeEach as bunBeforeEach, expect as bunExpect, test as bunTest } from "bun:test";
|
|
||||||
import type { ICacheItem, ICachePool } from "../../../src/core/cache/cache-interface";
|
|
||||||
|
|
||||||
export type TestOptions = {
|
|
||||||
createCache: () => Promise<ICachePool>;
|
|
||||||
createItem: (key: string, value: any) => ICacheItem;
|
|
||||||
tester: {
|
|
||||||
test: (name: string, fn: () => Promise<void>) => void;
|
|
||||||
beforeEach: (fn: () => Promise<void>) => void;
|
|
||||||
expect: (actual?: any) => {
|
|
||||||
toBe(expected: any): void;
|
|
||||||
toEqual(expected: any): void;
|
|
||||||
toBeUndefined(): void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function runTests({ createCache, createItem, tester }: TestOptions) {
|
|
||||||
let cache: ICachePool<string>;
|
|
||||||
const { test, beforeEach, expect } = tester;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
cache = await createCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getItem returns correct item", async () => {
|
|
||||||
const item = createItem("key1", "value1");
|
|
||||||
await cache.save(item);
|
|
||||||
const retrievedItem = await cache.get("key1");
|
|
||||||
expect(retrievedItem.value()).toEqual(item.value());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getItem returns new item when key does not exist", async () => {
|
|
||||||
const retrievedItem = await cache.get("key1");
|
|
||||||
expect(retrievedItem.key()).toEqual("key1");
|
|
||||||
expect(retrievedItem.value()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getItems returns correct items", async () => {
|
|
||||||
const item1 = createItem("key1", "value1");
|
|
||||||
const item2 = createItem("key2", "value2");
|
|
||||||
await cache.save(item1);
|
|
||||||
await cache.save(item2);
|
|
||||||
const retrievedItems = await cache.getMany(["key1", "key2"]);
|
|
||||||
expect(retrievedItems.get("key1")?.value()).toEqual(item1.value());
|
|
||||||
expect(retrievedItems.get("key2")?.value()).toEqual(item2.value());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hasItem returns true when item exists and is a hit", async () => {
|
|
||||||
const item = createItem("key1", "value1");
|
|
||||||
await cache.save(item);
|
|
||||||
expect(await cache.has("key1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clear and deleteItem correctly clear the cache and delete items", async () => {
|
|
||||||
const item = createItem("key1", "value1");
|
|
||||||
await cache.save(item);
|
|
||||||
|
|
||||||
if (cache.supports().clear) {
|
|
||||||
await cache.clear();
|
|
||||||
} else {
|
|
||||||
await cache.delete("key1");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(await cache.has("key1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("save correctly saves items to the cache", async () => {
|
|
||||||
const item = createItem("key1", "value1");
|
|
||||||
await cache.save(item);
|
|
||||||
expect(await cache.has("key1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("putItem correctly puts items in the cache ", async () => {
|
|
||||||
await cache.put("key1", "value1", { ttl: 60 });
|
|
||||||
const item = await cache.get("key1");
|
|
||||||
expect(item.value()).toEqual("value1");
|
|
||||||
expect(item.hit()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
/*test("commit returns true", async () => {
|
|
||||||
expect(await cache.commit()).toBe(true);
|
|
||||||
});*/
|
|
||||||
}
|
|
||||||
@@ -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" };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Perf, datetimeStringUTC, isBlob, ucFirst } from "../../src/core/utils";
|
import { Perf, ucFirst } from "../../src/core/utils";
|
||||||
import * as utils from "../../src/core/utils";
|
import * as utils from "../../src/core/utils";
|
||||||
|
import { assetsPath } from "../helper";
|
||||||
|
|
||||||
async function wait(ms: number) {
|
async function wait(ms: number) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -75,57 +76,6 @@ describe("Core Utils", async () => {
|
|||||||
const result3 = utils.encodeSearch(obj3, { encode: true });
|
const result3 = utils.encodeSearch(obj3, { encode: true });
|
||||||
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
expect(result3).toBe("id=123&name=%7B%22test%22%3A%22test%22%7D");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("guards", () => {
|
|
||||||
const types = {
|
|
||||||
blob: new Blob(),
|
|
||||||
file: new File([""], "file.txt"),
|
|
||||||
stream: new ReadableStream(),
|
|
||||||
arrayBuffer: new ArrayBuffer(10),
|
|
||||||
arrayBufferView: new Uint8Array(new ArrayBuffer(10)),
|
|
||||||
};
|
|
||||||
|
|
||||||
const fns = [
|
|
||||||
[utils.isReadableStream, "stream"],
|
|
||||||
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
|
|
||||||
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
|
|
||||||
[utils.isArrayBuffer, "arrayBuffer"],
|
|
||||||
[utils.isArrayBufferView, "arrayBufferView"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const additional = [0, 0.0, "", null, undefined, {}, []];
|
|
||||||
|
|
||||||
for (const [fn, type, _to_test] of fns) {
|
|
||||||
test(`is${ucFirst(type)}`, () => {
|
|
||||||
const to_test = _to_test ?? (Object.keys(types) as string[]);
|
|
||||||
for (const key of to_test) {
|
|
||||||
const value = types[key as keyof typeof types];
|
|
||||||
const result = fn(value);
|
|
||||||
expect(result).toBe(key === type);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const value of additional) {
|
|
||||||
const result = fn(value);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getContentName", () => {
|
|
||||||
const name = "test.json";
|
|
||||||
const text = "attachment; filename=" + name;
|
|
||||||
const headers = new Headers({
|
|
||||||
"Content-Disposition": text,
|
|
||||||
});
|
|
||||||
const request = new Request("http://example.com", {
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(utils.getContentName(text)).toBe(name);
|
|
||||||
expect(utils.getContentName(headers)).toBe(name);
|
|
||||||
expect(utils.getContentName(request)).toBe(name);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("perf", async () => {
|
describe("perf", async () => {
|
||||||
@@ -246,6 +196,76 @@ describe("Core Utils", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("file", async () => {
|
||||||
|
describe("type guards", () => {
|
||||||
|
const types = {
|
||||||
|
blob: new Blob(),
|
||||||
|
file: new File([""], "file.txt"),
|
||||||
|
stream: new ReadableStream(),
|
||||||
|
arrayBuffer: new ArrayBuffer(10),
|
||||||
|
arrayBufferView: new Uint8Array(new ArrayBuffer(10)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fns = [
|
||||||
|
[utils.isReadableStream, "stream"],
|
||||||
|
[utils.isBlob, "blob", ["stream", "arrayBuffer", "arrayBufferView"]],
|
||||||
|
[utils.isFile, "file", ["stream", "arrayBuffer", "arrayBufferView"]],
|
||||||
|
[utils.isArrayBuffer, "arrayBuffer"],
|
||||||
|
[utils.isArrayBufferView, "arrayBufferView"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const additional = [0, 0.0, "", null, undefined, {}, []];
|
||||||
|
|
||||||
|
for (const [fn, type, _to_test] of fns) {
|
||||||
|
test(`is${ucFirst(type)}`, () => {
|
||||||
|
const to_test = _to_test ?? (Object.keys(types) as string[]);
|
||||||
|
for (const key of to_test) {
|
||||||
|
const value = types[key as keyof typeof types];
|
||||||
|
const result = fn(value);
|
||||||
|
expect(result).toBe(key === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of additional) {
|
||||||
|
const result = fn(value);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getContentName", () => {
|
||||||
|
const name = "test.json";
|
||||||
|
const text = "attachment; filename=" + name;
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Disposition": text,
|
||||||
|
});
|
||||||
|
const request = new Request("http://example.com", {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(utils.getContentName(text)).toBe(name);
|
||||||
|
expect(utils.getContentName(headers)).toBe(name);
|
||||||
|
expect(utils.getContentName(request)).toBe(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.only("detectImageDimensions", async () => {
|
||||||
|
// wrong
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(utils.detectImageDimensions(new ArrayBuffer(), "text/plain")).rejects.toThrow();
|
||||||
|
|
||||||
|
// successful ones
|
||||||
|
const getFile = (name: string): File => Bun.file(`${assetsPath}/${name}`) as any;
|
||||||
|
expect(await utils.detectImageDimensions(getFile("image.png"))).toEqual({
|
||||||
|
width: 362,
|
||||||
|
height: 387,
|
||||||
|
});
|
||||||
|
expect(await utils.detectImageDimensions(getFile("image.jpg"))).toEqual({
|
||||||
|
width: 453,
|
||||||
|
height: 512,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("dates", () => {
|
describe("dates", () => {
|
||||||
test.only("formats local time", () => {
|
test.only("formats local time", () => {
|
||||||
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
expect(utils.datetimeStringUTC("2025-02-21T16:48:25.841Z")).toBe("2025-02-21 16:48:25");
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -288,14 +288,17 @@ describe("[data] Mutator (Events)", async () => {
|
|||||||
|
|
||||||
test("events were fired", async () => {
|
test("events were fired", async () => {
|
||||||
const { data } = await mutator.insertOne({ label: "test" });
|
const { data } = await mutator.insertOne({ label: "test" });
|
||||||
|
await mutator.emgr.executeAsyncs();
|
||||||
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorInsertBefore.slug)).toBeTrue();
|
||||||
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorInsertAfter.slug)).toBeTrue();
|
||||||
|
|
||||||
await mutator.updateOne(data.id, { label: "test2" });
|
await mutator.updateOne(data.id, { label: "test2" });
|
||||||
|
await mutator.emgr.executeAsyncs();
|
||||||
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorUpdateBefore.slug)).toBeTrue();
|
||||||
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorUpdateAfter.slug)).toBeTrue();
|
||||||
|
|
||||||
await mutator.deleteOne(data.id);
|
await mutator.deleteOne(data.id);
|
||||||
|
await mutator.emgr.executeAsyncs();
|
||||||
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorDeleteBefore.slug)).toBeTrue();
|
||||||
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
|
expect(events.has(MutatorEvents.MutatorDeleteAfter.slug)).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterAll, describe, expect, test } from "bun:test";
|
import { afterAll, describe, expect, test } from "bun:test";
|
||||||
import type { Kysely, Transaction } from "kysely";
|
import type { Kysely, Transaction } from "kysely";
|
||||||
import { Perf } from "../../../src/core/utils";
|
import { Perf } from "core/utils";
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
EntityManager,
|
EntityManager,
|
||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
ManyToOneRelation,
|
ManyToOneRelation,
|
||||||
RepositoryEvents,
|
RepositoryEvents,
|
||||||
TextField,
|
TextField,
|
||||||
} from "../../../src/data";
|
entity as $entity,
|
||||||
|
text as $text,
|
||||||
|
em as $em,
|
||||||
|
} from "data";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
|
|
||||||
type E = Kysely<any> | Transaction<any>;
|
type E = Kysely<any> | Transaction<any>;
|
||||||
@@ -177,6 +180,47 @@ describe("[Repository]", async () => {
|
|||||||
const res5 = await em.repository(items).exists({});
|
const res5 = await em.repository(items).exists({});
|
||||||
expect(res5.exists).toBe(true);
|
expect(res5.exists).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("option: silent", async () => {
|
||||||
|
const em = $em({
|
||||||
|
items: $entity("items", {
|
||||||
|
label: $text(),
|
||||||
|
}),
|
||||||
|
}).proto.withConnection(getDummyConnection().dummyConnection);
|
||||||
|
|
||||||
|
// should throw because table doesn't exist
|
||||||
|
expect(em.repo("items").findMany({})).rejects.toThrow(/no such table/);
|
||||||
|
// should silently return empty result
|
||||||
|
expect(
|
||||||
|
em
|
||||||
|
.repo("items", { silent: true })
|
||||||
|
.findMany({})
|
||||||
|
.then((r) => r.data),
|
||||||
|
).resolves.toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("option: includeCounts", async () => {
|
||||||
|
const em = $em({
|
||||||
|
items: $entity("items", {
|
||||||
|
label: $text(),
|
||||||
|
}),
|
||||||
|
}).proto.withConnection(getDummyConnection().dummyConnection);
|
||||||
|
await em.schema().sync({ force: true });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
em
|
||||||
|
.repo("items")
|
||||||
|
.findMany({})
|
||||||
|
.then((r) => [r.meta.count, r.meta.total]),
|
||||||
|
).resolves.toEqual([0, 0]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
em
|
||||||
|
.repo("items", { includeCounts: false })
|
||||||
|
.findMany({})
|
||||||
|
.then((r) => [r.meta.count, r.meta.total]),
|
||||||
|
).resolves.toEqual([undefined, undefined]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("[data] Repository (Events)", async () => {
|
describe("[data] Repository (Events)", async () => {
|
||||||
@@ -198,24 +242,36 @@ describe("[data] Repository (Events)", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("events were fired", async () => {
|
test("events were fired", async () => {
|
||||||
await em.repository(items).findId(1);
|
const repo = em.repository(items);
|
||||||
|
await repo.findId(1);
|
||||||
|
await repo.emgr.executeAsyncs();
|
||||||
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
|
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
|
||||||
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
|
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
|
||||||
events.clear();
|
events.clear();
|
||||||
|
|
||||||
await em.repository(items).findOne({ id: 1 });
|
await repo.findOne({ id: 1 });
|
||||||
|
await repo.emgr.executeAsyncs();
|
||||||
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
|
expect(events.has(RepositoryEvents.RepositoryFindOneBefore.slug)).toBeTrue();
|
||||||
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
|
expect(events.has(RepositoryEvents.RepositoryFindOneAfter.slug)).toBeTrue();
|
||||||
events.clear();
|
events.clear();
|
||||||
|
|
||||||
await em.repository(items).findMany({ where: { id: 1 } });
|
await repo.findMany({ where: { id: 1 } });
|
||||||
|
await repo.emgr.executeAsyncs();
|
||||||
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();
|
||||||
|
|
||||||
await em.repository(items).findManyByReference(1, "categories");
|
await repo.findManyByReference(1, "categories");
|
||||||
|
await repo.emgr.executeAsyncs();
|
||||||
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-80
@@ -1,25 +1,18 @@
|
|||||||
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) => {
|
function qb() {
|
||||||
const result = Value.Decode(querySchema, input);
|
const c = getDummyConnection();
|
||||||
expect(result).toEqual(expected);
|
const kysely = c.dummyConnection.kysely;
|
||||||
};
|
return kysely.selectFrom("t").selectAll();
|
||||||
|
}
|
||||||
describe("data-query-impl", () => {
|
function compile(q: WhereQuery) {
|
||||||
function qb() {
|
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
||||||
const c = getDummyConnection();
|
return { sql, parameters };
|
||||||
const kysely = c.dummyConnection.kysely;
|
}
|
||||||
return kysely.selectFrom("t").selectAll();
|
|
||||||
}
|
|
||||||
function compile(q: WhereQuery) {
|
|
||||||
const { sql, parameters } = WhereBuilder.addClause(qb(), q).compile();
|
|
||||||
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" } });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { BooleanField } from "../../../../src/data";
|
import { BooleanField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] BooleanField", async () => {
|
describe("[data] BooleanField", async () => {
|
||||||
runBaseFieldTests(BooleanField, { defaultValue: true, schemaType: "boolean" });
|
fieldTestSuite({ expect, test }, BooleanField, { defaultValue: true, schemaType: "boolean" });
|
||||||
|
|
||||||
test("transformRetrieve", async () => {
|
test("transformRetrieve", async () => {
|
||||||
const field = new BooleanField("test");
|
const field = new BooleanField("test");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { DateField } from "../../../../src/data";
|
import { DateField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests } from "./inc";
|
import { fieldTestSuite } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] DateField", async () => {
|
describe("[data] DateField", async () => {
|
||||||
runBaseFieldTests(DateField, { defaultValue: new Date(), schemaType: "date" });
|
fieldTestSuite({ expect, test }, DateField, { defaultValue: new Date(), schemaType: "date" });
|
||||||
|
|
||||||
// @todo: add datefield tests
|
// @todo: add datefield tests
|
||||||
test("week", async () => {
|
test("week", async () => {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { EnumField } from "../../../../src/data";
|
import { EnumField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
function options(strings: string[]) {
|
function options(strings: string[]) {
|
||||||
return { type: "strings", values: strings };
|
return { type: "strings", values: strings };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("[data] EnumField", async () => {
|
describe("[data] EnumField", async () => {
|
||||||
runBaseFieldTests(
|
fieldTestSuite(
|
||||||
|
{ expect, test },
|
||||||
|
// @ts-ignore
|
||||||
EnumField,
|
EnumField,
|
||||||
{ defaultValue: "a", schemaType: "text" },
|
{ defaultValue: "a", schemaType: "text" },
|
||||||
{ options: options(["a", "b", "c"]) },
|
{ options: options(["a", "b", "c"]) },
|
||||||
@@ -15,11 +17,13 @@ describe("[data] EnumField", async () => {
|
|||||||
|
|
||||||
test("yields if default value is not a valid option", async () => {
|
test("yields if default value is not a valid option", async () => {
|
||||||
expect(
|
expect(
|
||||||
|
// @ts-ignore
|
||||||
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
|
() => new EnumField("test", { options: options(["a", "b"]), default_value: "c" }),
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("transformPersist (config)", async () => {
|
test("transformPersist (config)", async () => {
|
||||||
|
// @ts-ignore
|
||||||
const field = new EnumField("test", { options: options(["a", "b", "c"]) });
|
const field = new EnumField("test", { options: options(["a", "b", "c"]) });
|
||||||
|
|
||||||
expect(transformPersist(field, null)).resolves.toBeUndefined();
|
expect(transformPersist(field, null)).resolves.toBeUndefined();
|
||||||
@@ -29,6 +33,7 @@ describe("[data] EnumField", async () => {
|
|||||||
|
|
||||||
test("transformRetrieve", async () => {
|
test("transformRetrieve", async () => {
|
||||||
const field = new EnumField("test", {
|
const field = new EnumField("test", {
|
||||||
|
// @ts-ignore
|
||||||
options: options(["a", "b", "c"]),
|
options: options(["a", "b", "c"]),
|
||||||
default_value: "a",
|
default_value: "a",
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Default, stripMark } from "../../../../src/core/utils";
|
import { Default, stripMark } from "../../../../src/core/utils";
|
||||||
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
|
import { baseFieldConfigSchema, Field } from "../../../../src/data/fields/Field";
|
||||||
import { runBaseFieldTests } from "./inc";
|
import { fieldTestSuite } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] Field", async () => {
|
describe("[data] Field", async () => {
|
||||||
class FieldSpec extends Field {
|
class FieldSpec extends Field {
|
||||||
@@ -19,7 +19,7 @@ describe("[data] Field", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
runBaseFieldTests(FieldSpec, { defaultValue: "test", schemaType: "text" });
|
fieldTestSuite({ expect, test }, FieldSpec, { defaultValue: "test", schemaType: "text" });
|
||||||
|
|
||||||
test("default config", async () => {
|
test("default config", async () => {
|
||||||
const config = Default(baseFieldConfigSchema, {});
|
const config = Default(baseFieldConfigSchema, {});
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
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 {
|
import { Entity, EntityIndex, Field } from "../../../../src/data";
|
||||||
Entity,
|
|
||||||
EntityIndex,
|
|
||||||
type EntityManager,
|
|
||||||
Field,
|
|
||||||
type SchemaResponse,
|
|
||||||
} from "../../../../src/data";
|
|
||||||
|
|
||||||
class TestField extends Field {
|
class TestField extends Field {
|
||||||
protected getSchema(): any {
|
protected getSchema(): any {
|
||||||
return Type.Any();
|
return Type.Any();
|
||||||
}
|
}
|
||||||
|
|
||||||
schema(em: EntityManager<any>): SchemaResponse {
|
override schema() {
|
||||||
return undefined as any;
|
return undefined as any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { JsonField } from "../../../../src/data";
|
import { JsonField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] JsonField", async () => {
|
describe("[data] JsonField", async () => {
|
||||||
const field = new JsonField("test");
|
const field = new JsonField("test");
|
||||||
runBaseFieldTests(JsonField, {
|
fieldTestSuite({ expect, test }, JsonField, {
|
||||||
defaultValue: { a: 1 },
|
defaultValue: { a: 1 },
|
||||||
sampleValues: ["string", { test: 1 }, 1],
|
sampleValues: ["string", { test: 1 }, 1],
|
||||||
schemaType: "text",
|
schemaType: "text",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { JsonSchemaField } from "../../../../src/data";
|
import { JsonSchemaField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests } from "./inc";
|
import { fieldTestSuite } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] JsonSchemaField", async () => {
|
describe("[data] JsonSchemaField", async () => {
|
||||||
runBaseFieldTests(JsonSchemaField, { defaultValue: {}, schemaType: "text" });
|
// @ts-ignore
|
||||||
|
fieldTestSuite({ expect, test }, JsonSchemaField, { defaultValue: {}, schemaType: "text" });
|
||||||
|
|
||||||
// @todo: add JsonSchemaField tests
|
// @todo: add JsonSchemaField tests
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { NumberField } from "../../../../src/data";
|
import { NumberField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] NumberField", async () => {
|
describe("[data] NumberField", async () => {
|
||||||
test("transformPersist (config)", async () => {
|
test("transformPersist (config)", async () => {
|
||||||
@@ -15,5 +15,5 @@ describe("[data] NumberField", async () => {
|
|||||||
expect(transformPersist(field2, 10000)).resolves.toBe(10000);
|
expect(transformPersist(field2, 10000)).resolves.toBe(10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
runBaseFieldTests(NumberField, { defaultValue: 12, schemaType: "integer" });
|
fieldTestSuite({ expect, test }, NumberField, { defaultValue: 12, schemaType: "integer" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { TextField } from "../../../../src/data";
|
import { TextField } from "../../../../src/data";
|
||||||
import { runBaseFieldTests, transformPersist } from "./inc";
|
import { fieldTestSuite, transformPersist } from "data/fields/field-test-suite";
|
||||||
|
|
||||||
describe("[data] TextField", async () => {
|
describe("[data] TextField", async () => {
|
||||||
test("transformPersist (config)", async () => {
|
test("transformPersist (config)", async () => {
|
||||||
@@ -11,5 +11,5 @@ describe("[data] TextField", async () => {
|
|||||||
expect(transformPersist(field, "abc")).resolves.toBe("abc");
|
expect(transformPersist(field, "abc")).resolves.toBe("abc");
|
||||||
});
|
});
|
||||||
|
|
||||||
runBaseFieldTests(TextField, { defaultValue: "abc", schemaType: "text" });
|
fieldTestSuite({ expect, test }, TextField, { defaultValue: "abc", schemaType: "text" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export const assetsTmpPath = `${import.meta.dir}/_assets/tmp`;
|
|||||||
export async function enableFetchLogging() {
|
export async function enableFetchLogging() {
|
||||||
const originalFetch = global.fetch;
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const response = await originalFetch(input, init);
|
const response = await originalFetch(input, init);
|
||||||
const url = input instanceof URL || typeof input === "string" ? input : input.url;
|
const url = input instanceof URL || typeof input === "string" ? input : input.url;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|||||||
import { createApp, registries } from "../../src";
|
import { createApp, registries } from "../../src";
|
||||||
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 "../../src/media/storage/adapters/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
|
import { assetsPath, assetsTmpPath, disableConsoleLog, enableConsoleLog } from "../helper";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -43,8 +43,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,8 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { type FileBody, Storage, type StorageAdapter } from "../../src/media/storage/Storage";
|
import { type FileBody, Storage } from "../../src/media/storage/Storage";
|
||||||
import * as StorageEvents from "../../src/media/storage/events";
|
import * as StorageEvents from "../../src/media/storage/events";
|
||||||
|
import { StorageAdapter } from "media";
|
||||||
|
|
||||||
class TestAdapter implements StorageAdapter {
|
class TestAdapter extends StorageAdapter {
|
||||||
files: Record<string, FileBody> = {};
|
files: Record<string, FileBody> = {};
|
||||||
|
|
||||||
getName() {
|
getName() {
|
||||||
@@ -61,7 +62,7 @@ describe("Storage", async () => {
|
|||||||
test("uploads a file", async () => {
|
test("uploads a file", async () => {
|
||||||
const {
|
const {
|
||||||
meta: { type, size },
|
meta: { type, size },
|
||||||
} = await storage.uploadFile("hello", "world.txt");
|
} = await storage.uploadFile("hello" as any, "world.txt");
|
||||||
expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
|
expect({ type, size }).toEqual({ type: "text/plain", size: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ describe("Storage", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("events were fired", async () => {
|
test("events were fired", async () => {
|
||||||
|
await storage.emgr.executeAsyncs();
|
||||||
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
|
expect(events.has(StorageEvents.FileUploadedEvent.slug)).toBeTrue();
|
||||||
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
|
expect(events.has(StorageEvents.FileDeletedEvent.slug)).toBeTrue();
|
||||||
// @todo: file access must be tested in controllers
|
// @todo: file access must be tested in controllers
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import * as assert from "node:assert/strict";
|
|
||||||
import { createWriteStream } from "node:fs";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { Miniflare } from "miniflare";
|
|
||||||
|
|
||||||
// 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("what", async () => {
|
|
||||||
const mf = new Miniflare({
|
|
||||||
modules: true,
|
|
||||||
script: "export default { async fetch() { return new Response(null); } }",
|
|
||||||
r2Buckets: ["BUCKET"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const bucket = await mf.getR2Bucket("BUCKET");
|
|
||||||
console.log(await bucket.put("count", "1"));
|
|
||||||
|
|
||||||
const object = await bucket.get("count");
|
|
||||||
if (object) {
|
|
||||||
/*const headers = new Headers();
|
|
||||||
object.writeHttpMetadata(headers);
|
|
||||||
headers.set("etag", object.httpEtag);*/
|
|
||||||
console.log("yo -->", await object.text());
|
|
||||||
|
|
||||||
assert.strictEqual(await object.text(), "1");
|
|
||||||
}
|
|
||||||
|
|
||||||
await mf.dispose();
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { randomString } from "../../../src/core/utils";
|
|
||||||
import { StorageCloudinaryAdapter } from "../../../src/media";
|
|
||||||
|
|
||||||
import { config } from "dotenv";
|
|
||||||
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
|
|
||||||
const {
|
|
||||||
CLOUDINARY_CLOUD_NAME,
|
|
||||||
CLOUDINARY_API_KEY,
|
|
||||||
CLOUDINARY_API_SECRET,
|
|
||||||
CLOUDINARY_UPLOAD_PRESET,
|
|
||||||
} = dotenvOutput.parsed!;
|
|
||||||
|
|
||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
|
||||||
|
|
||||||
describe.skipIf(ALL_TESTS)("StorageCloudinaryAdapter", () => {
|
|
||||||
if (ALL_TESTS) return;
|
|
||||||
|
|
||||||
const adapter = new StorageCloudinaryAdapter({
|
|
||||||
cloud_name: CLOUDINARY_CLOUD_NAME as string,
|
|
||||||
api_key: CLOUDINARY_API_KEY as string,
|
|
||||||
api_secret: CLOUDINARY_API_SECRET as string,
|
|
||||||
upload_preset: CLOUDINARY_UPLOAD_PRESET as string,
|
|
||||||
});
|
|
||||||
|
|
||||||
const file = Bun.file(`${import.meta.dir}/icon.png`);
|
|
||||||
const _filename = randomString(10);
|
|
||||||
const filename = `${_filename}.png`;
|
|
||||||
|
|
||||||
test("object exists", async () => {
|
|
||||||
expect(await adapter.objectExists("7fCTBi6L8c.png")).toBeTrue();
|
|
||||||
process.exit();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("puts object", async () => {
|
|
||||||
expect(await adapter.objectExists(filename)).toBeFalse();
|
|
||||||
|
|
||||||
const result = await adapter.putObject(filename, file);
|
|
||||||
console.log("result", result);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result?.name).toBe(filename);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("object exists", async () => {
|
|
||||||
await Bun.sleep(10000);
|
|
||||||
const one = await adapter.objectExists(_filename);
|
|
||||||
const two = await adapter.objectExists(filename);
|
|
||||||
expect(await adapter.objectExists(filename)).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("object meta", async () => {
|
|
||||||
const result = await adapter.getObjectMeta(filename);
|
|
||||||
console.log("objectMeta:result", result);
|
|
||||||
expect(result).toBeDefined();
|
|
||||||
expect(result.type).toBe("image/png");
|
|
||||||
expect(result.size).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("list objects", async () => {
|
|
||||||
const result = await adapter.listObjects();
|
|
||||||
console.log("listObjects:result", result);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { randomString } from "../../../src/core/utils";
|
|
||||||
import { StorageLocalAdapter } from "../../../src/media/storage/adapters/StorageLocalAdapter";
|
|
||||||
import { assetsPath, assetsTmpPath } from "../../helper";
|
|
||||||
|
|
||||||
describe("StorageLocalAdapter", () => {
|
|
||||||
const adapter = new StorageLocalAdapter({
|
|
||||||
path: assetsTmpPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const file = Bun.file(`${assetsPath}/image.png`);
|
|
||||||
const _filename = randomString(10);
|
|
||||||
const filename = `${_filename}.png`;
|
|
||||||
|
|
||||||
let objects = 0;
|
|
||||||
|
|
||||||
test("puts an object", async () => {
|
|
||||||
objects = (await adapter.listObjects()).length;
|
|
||||||
expect(await adapter.putObject(filename, file as unknown as File)).toBeString();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("lists objects", async () => {
|
|
||||||
expect((await adapter.listObjects()).length).toBe(objects + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("file exists", async () => {
|
|
||||||
expect(await adapter.objectExists(filename)).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gets an object", async () => {
|
|
||||||
const res = await adapter.getObject(filename, new Headers());
|
|
||||||
expect(res.ok).toBeTrue();
|
|
||||||
// @todo: check the content
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gets object meta", async () => {
|
|
||||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
|
||||||
type: file.type, // image/png
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deletes an object", async () => {
|
|
||||||
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
|
||||||
expect(await adapter.objectExists(filename)).toBeFalse();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
||||||
import { randomString } from "../../../src/core/utils";
|
|
||||||
import { StorageS3Adapter } from "../../../src/media";
|
|
||||||
|
|
||||||
import { config } from "dotenv";
|
|
||||||
//import { enableFetchLogging } from "../../helper";
|
|
||||||
const dotenvOutput = config({ path: `${import.meta.dir}/../../../.env` });
|
|
||||||
const { R2_ACCESS_KEY, R2_SECRET_ACCESS_KEY, R2_URL, AWS_ACCESS_KEY, AWS_SECRET_KEY, AWS_S3_URL } =
|
|
||||||
dotenvOutput.parsed!;
|
|
||||||
|
|
||||||
// @todo: mock r2/s3 responses for faster tests
|
|
||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
|
||||||
console.log("ALL_TESTS?", ALL_TESTS);
|
|
||||||
|
|
||||||
/*
|
|
||||||
// @todo: preparation to mock s3 calls + replace fast-xml-parser
|
|
||||||
let cleanup: () => void;
|
|
||||||
beforeAll(async () => {
|
|
||||||
cleanup = await enableFetchLogging();
|
|
||||||
});
|
|
||||||
afterAll(() => {
|
|
||||||
cleanup();
|
|
||||||
}); */
|
|
||||||
|
|
||||||
describe.skipIf(ALL_TESTS)("StorageS3Adapter", async () => {
|
|
||||||
if (ALL_TESTS) return;
|
|
||||||
|
|
||||||
const versions = [
|
|
||||||
[
|
|
||||||
"r2",
|
|
||||||
new StorageS3Adapter({
|
|
||||||
access_key: R2_ACCESS_KEY as string,
|
|
||||||
secret_access_key: R2_SECRET_ACCESS_KEY as string,
|
|
||||||
url: R2_URL as string,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"s3",
|
|
||||||
new StorageS3Adapter({
|
|
||||||
access_key: AWS_ACCESS_KEY as string,
|
|
||||||
secret_access_key: AWS_SECRET_KEY as string,
|
|
||||||
url: AWS_S3_URL as string,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const _conf = {
|
|
||||||
adapters: ["r2", "s3"],
|
|
||||||
tests: [
|
|
||||||
"listObjects",
|
|
||||||
"putObject",
|
|
||||||
"objectExists",
|
|
||||||
"getObject",
|
|
||||||
"deleteObject",
|
|
||||||
"getObjectMeta",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const file = Bun.file(`${import.meta.dir}/icon.png`);
|
|
||||||
const filename = `${randomString(10)}.png`;
|
|
||||||
|
|
||||||
// single (dev)
|
|
||||||
//_conf = { adapters: [/*"r2",*/ "s3"], tests: [/*"putObject",*/ "listObjects"] };
|
|
||||||
|
|
||||||
function disabled(test: (typeof _conf.tests)[number]) {
|
|
||||||
return !_conf.tests.includes(test);
|
|
||||||
}
|
|
||||||
|
|
||||||
// @todo: add mocked fetch for faster tests
|
|
||||||
describe.each(versions)("StorageS3Adapter for %s", async (name, adapter) => {
|
|
||||||
if (!_conf.adapters.includes(name) || ALL_TESTS) {
|
|
||||||
console.log("Skipping", name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let objects = 0;
|
|
||||||
|
|
||||||
test.skipIf(disabled("putObject"))("puts an object", async () => {
|
|
||||||
objects = (await adapter.listObjects()).length;
|
|
||||||
expect(await adapter.putObject(filename, file as any)).toBeString();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(disabled("listObjects"))("lists objects", async () => {
|
|
||||||
expect((await adapter.listObjects()).length).toBe(objects + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(disabled("objectExists"))("file exists", async () => {
|
|
||||||
expect(await adapter.objectExists(filename)).toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(disabled("getObject"))("gets an object", async () => {
|
|
||||||
const res = await adapter.getObject(filename, new Headers());
|
|
||||||
expect(res.ok).toBeTrue();
|
|
||||||
// @todo: check the content
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(disabled("getObjectMeta"))("gets object meta", async () => {
|
|
||||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
|
||||||
type: file.type, // image/png
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skipIf(disabled("deleteObject"))("deletes an object", async () => {
|
|
||||||
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
|
||||||
expect(await adapter.objectExists(filename)).toBeFalse();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,9 @@ import { getRandomizedFilename } from "../../src/media/utils";
|
|||||||
|
|
||||||
describe("media/mime-types", () => {
|
describe("media/mime-types", () => {
|
||||||
test("tiny resolves", () => {
|
test("tiny resolves", () => {
|
||||||
const tests = [[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"]];
|
const tests = [
|
||||||
|
[".mp4", "video/mp4", ".jpg", "image/jpeg", ".zip", "application/zip"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
for (const [ext, mime] of tests) {
|
for (const [ext, mime] of tests) {
|
||||||
expect(tiny.guess(ext)).toBe(mime);
|
expect(tiny.guess(ext)).toBe(mime);
|
||||||
@@ -69,7 +71,7 @@ describe("media/mime-types", () => {
|
|||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
["text/tab-separated-values", "tsv"],
|
["text/tab-separated-values", "tsv"],
|
||||||
["application/zip", "zip"],
|
["application/zip", "zip"],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
for (const [mime, ext] of tests) {
|
for (const [mime, ext] of tests) {
|
||||||
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
|
expect(tiny.extension(mime), `extension(): ${mime} should be ${ext}`).toBe(ext);
|
||||||
@@ -86,7 +88,7 @@ describe("media/mime-types", () => {
|
|||||||
["image.jpeg", "jpeg"],
|
["image.jpeg", "jpeg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
["-473Wx593H-466453554-black-MODEL.jpg", "jpg"],
|
||||||
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
["-473Wx593H-466453554-black-MODEL.avif", "avif"],
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
for (const [filename, ext] of tests) {
|
for (const [filename, ext] of tests) {
|
||||||
expect(
|
expect(
|
||||||
@@ -94,5 +96,10 @@ describe("media/mime-types", () => {
|
|||||||
`getRandomizedFilename(): ${filename} should end with ${ext}`,
|
`getRandomizedFilename(): ${filename} should end with ${ext}`,
|
||||||
).toBe(ext);
|
).toBe(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// make sure it keeps the extension, even if the file has a different type
|
||||||
|
const file = new File([""], "image.jpg", { type: "text/plain" });
|
||||||
|
const [, ext] = getRandomizedFilename(file).split(".");
|
||||||
|
expect(ext).toBe("jpg");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -1,13 +1,51 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
import { parse } from "../../src/core/utils";
|
import { parse } from "../../src/core/utils";
|
||||||
import { fieldsSchema } from "../../src/data/data-schema";
|
import { fieldsSchema } from "../../src/data/data-schema";
|
||||||
import { AppData } from "../../src/modules";
|
import { AppData, type ModuleBuildContext } from "../../src/modules";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { makeCtx, moduleTestSuite } from "./module-test-suite";
|
||||||
|
import * as proto from "data/prototype";
|
||||||
|
|
||||||
describe("AppData", () => {
|
describe("AppData", () => {
|
||||||
moduleTestSuite(AppData);
|
moduleTestSuite(AppData);
|
||||||
|
|
||||||
|
let ctx: ModuleBuildContext;
|
||||||
|
beforeEach(() => {
|
||||||
|
ctx = makeCtx();
|
||||||
|
});
|
||||||
|
|
||||||
test("field config construction", () => {
|
test("field config construction", () => {
|
||||||
expect(parse(fieldsSchema, { type: "text" })).toBeDefined();
|
expect(parse(fieldsSchema, { type: "text" })).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should prevent multi-deletion of entities in single request", async () => {
|
||||||
|
const schema = proto.em({
|
||||||
|
one: proto.entity("one", {
|
||||||
|
text: proto.text(),
|
||||||
|
}),
|
||||||
|
two: proto.entity("two", {
|
||||||
|
text: proto.text(),
|
||||||
|
}),
|
||||||
|
three: proto.entity("three", {
|
||||||
|
text: proto.text(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const check = () => {
|
||||||
|
const expected = ["one", "two", "three"];
|
||||||
|
const fromConfig = Object.keys(data.config.entities ?? {});
|
||||||
|
const fromEm = data.em.entities.map((e) => e.name);
|
||||||
|
expect(fromConfig).toEqual(expected);
|
||||||
|
expect(fromEm).toEqual(expected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// auth must be enabled, otherwise default config is returned
|
||||||
|
const data = new AppData(schema.toJSON(), ctx);
|
||||||
|
await data.build();
|
||||||
|
check();
|
||||||
|
|
||||||
|
expect(data.schema().remove("entities")).rejects.toThrow(/more than one entity/);
|
||||||
|
check();
|
||||||
|
|
||||||
|
await data.setContext(makeCtx()).build();
|
||||||
|
check();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createApp, registries } from "../../src";
|
import { createApp, registries } from "../../src";
|
||||||
import { em, entity, text } from "../../src/data";
|
import { em, entity, text } from "../../src/data";
|
||||||
import { StorageLocalAdapter } from "../../src/media/storage/adapters/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/modules";
|
import { AppMedia } from "../../src/modules";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { moduleTestSuite } from "./module-test-suite";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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";
|
||||||
@@ -9,10 +10,10 @@ function createModule<Schema extends TSchema>(schema: Schema) {
|
|||||||
getSchema() {
|
getSchema() {
|
||||||
return schema;
|
return schema;
|
||||||
}
|
}
|
||||||
toJSON() {
|
override toJSON() {
|
||||||
return this.config;
|
return this.config;
|
||||||
}
|
}
|
||||||
useForceParse() {
|
override useForceParse() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
import { Type, disableConsoleLog, enableConsoleLog, stripMark } from "../../src/core/utils";
|
import { disableConsoleLog, enableConsoleLog, stripMark } from "core/utils";
|
||||||
import { entity, text } from "../../src/data";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { Module } from "../../src/modules/Module";
|
import { Connection, entity, text } from "data";
|
||||||
import { ModuleManager, getDefaultConfig } 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 type { Static } from "@sinclair/typebox";
|
||||||
|
|
||||||
describe("ModuleManager", async () => {
|
describe("ModuleManager", async () => {
|
||||||
test("s1: no config, no build", async () => {
|
test("s1: no config, no build", async () => {
|
||||||
@@ -380,4 +383,128 @@ describe("ModuleManager", async () => {
|
|||||||
expect(() => f.default()).toThrow();
|
expect(() => f.default()).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function getRawConfig(c: Connection) {
|
||||||
|
return (await c.kysely
|
||||||
|
.selectFrom(TABLE_NAME)
|
||||||
|
.selectAll()
|
||||||
|
.where("type", "=", "config")
|
||||||
|
.orderBy("version", "desc")
|
||||||
|
.executeTakeFirstOrThrow()) as unknown as ConfigTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDiffs(c: Connection, opts?: { dir?: "asc" | "desc"; limit?: number }) {
|
||||||
|
return await c.kysely
|
||||||
|
.selectFrom(TABLE_NAME)
|
||||||
|
.selectAll()
|
||||||
|
.where("type", "=", "diff")
|
||||||
|
.orderBy("version", opts?.dir ?? "desc")
|
||||||
|
.$if(!!opts?.limit, (b) => b.limit(opts!.limit!))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("diffs", () => {
|
||||||
|
test("never empty", async () => {
|
||||||
|
const { dummyConnection: c } = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c);
|
||||||
|
await mm.build();
|
||||||
|
await mm.save();
|
||||||
|
expect(await getDiffs(c)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("has timestamps", async () => {
|
||||||
|
const { dummyConnection: c } = getDummyConnection();
|
||||||
|
const mm = new ModuleManager(c);
|
||||||
|
await mm.build();
|
||||||
|
|
||||||
|
await mm.get("data").schema().patch("basepath", "/api/data2");
|
||||||
|
await mm.save();
|
||||||
|
|
||||||
|
const config = await getRawConfig(c);
|
||||||
|
const diffs = await getDiffs(c);
|
||||||
|
|
||||||
|
expect(config.json.data.basepath).toBe("/api/data2");
|
||||||
|
expect(diffs).toHaveLength(1);
|
||||||
|
expect(diffs[0]!.created_at).toBeDefined();
|
||||||
|
expect(diffs[0]!.updated_at).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validate & revert", () => {
|
||||||
|
const schema = Type.Object({
|
||||||
|
value: Type.Array(Type.Number(), { default: [] }),
|
||||||
|
});
|
||||||
|
type SampleSchema = Static<typeof schema>;
|
||||||
|
class Sample extends Module<typeof schema> {
|
||||||
|
getSchema() {
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
override async build() {
|
||||||
|
this.setBuilt();
|
||||||
|
}
|
||||||
|
override async onBeforeUpdate(from: SampleSchema, to: SampleSchema) {
|
||||||
|
if (to.value.length > 3) {
|
||||||
|
throw new Error("too many values");
|
||||||
|
}
|
||||||
|
if (to.value.includes(7)) {
|
||||||
|
throw new Error("contains 7");
|
||||||
|
}
|
||||||
|
|
||||||
|
return to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TestModuleManager extends ModuleManager {
|
||||||
|
constructor(...args: ConstructorParameters<typeof ModuleManager>) {
|
||||||
|
super(...args);
|
||||||
|
this.modules["module1"] = new Sample({}, this.ctx());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
test("respects module onBeforeUpdate", async () => {
|
||||||
|
const { dummyConnection: c } = getDummyConnection();
|
||||||
|
const mm = new TestModuleManager(c);
|
||||||
|
await mm.build();
|
||||||
|
|
||||||
|
const m = mm.get("module1" as any) as Sample;
|
||||||
|
|
||||||
|
{
|
||||||
|
expect(async () => {
|
||||||
|
await m.schema().set({ value: [1, 2, 3, 4, 5] });
|
||||||
|
return mm.save();
|
||||||
|
}).toThrow(/too many values/);
|
||||||
|
|
||||||
|
expect(m.config.value).toHaveLength(0);
|
||||||
|
expect((mm.configs() as any).module1.value).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
expect(async () => {
|
||||||
|
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 2, 3, 4, 5] });
|
||||||
|
return mm.save();
|
||||||
|
}).toThrow(/too many values/);
|
||||||
|
|
||||||
|
expect(m.config.value).toHaveLength(0);
|
||||||
|
expect((mm.configs() as any).module1.value).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
expect(async () => {
|
||||||
|
await m.schema().set({ value: [1, 7, 5] });
|
||||||
|
return mm.save();
|
||||||
|
}).toThrow(/contains 7/);
|
||||||
|
|
||||||
|
expect(m.config.value).toHaveLength(0);
|
||||||
|
expect((mm.configs() as any).module1.value).toHaveLength(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
expect(async () => {
|
||||||
|
await mm.mutateConfigSafe("module1" as any).set({ value: [1, 7, 5] });
|
||||||
|
return mm.save();
|
||||||
|
}).toThrow(/contains 7/);
|
||||||
|
|
||||||
|
expect(m.config.value).toHaveLength(0);
|
||||||
|
expect((mm.configs() as any).module1.value).toHaveLength(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("Example Test Suite", () => {
|
||||||
|
it("should pass basic arithmetic", () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle async operations", async () => {
|
||||||
|
const result = await Promise.resolve(42);
|
||||||
|
expect(result).toBe(42);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Automatically cleanup after each test
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -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 + "/", "")),
|
||||||
|
);
|
||||||
|
}
|
||||||
+25
-10
@@ -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,14 @@ 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", "@libsql/client"] as const;
|
const external = ["bun:test", "node:test", "node:assert/strict", "@libsql/client"] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
@@ -65,7 +71,14 @@ async function buildApi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
entry: ["src/index.ts", "src/data/index.ts", "src/core/index.ts", "src/core/utils/index.ts"],
|
define,
|
||||||
|
entry: [
|
||||||
|
"src/index.ts",
|
||||||
|
"src/core/index.ts",
|
||||||
|
"src/core/utils/index.ts",
|
||||||
|
"src/data/index.ts",
|
||||||
|
"src/media/index.ts",
|
||||||
|
],
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
external: [...external],
|
external: [...external],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
@@ -95,6 +108,7 @@ async function buildUi() {
|
|||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch,
|
watch,
|
||||||
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
@@ -154,6 +168,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: [
|
||||||
@@ -205,7 +220,7 @@ function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsu
|
|||||||
},
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
...define,
|
||||||
...overrides.define,
|
...overrides.define,
|
||||||
},
|
},
|
||||||
external: [
|
external: [
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { $ } from "bun";
|
||||||
|
import path from "node:path";
|
||||||
|
import c from "picocolors";
|
||||||
|
|
||||||
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
|
|
||||||
|
async function run(
|
||||||
|
cmd: string[] | string,
|
||||||
|
opts: Bun.SpawnOptions.OptionsObject & {},
|
||||||
|
onChunk: (chunk: string, resolve: (data: any) => void, reject: (err: Error) => void) => void,
|
||||||
|
): Promise<{ proc: Bun.Subprocess; data: any }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = Bun.spawn(Array.isArray(cmd) ? cmd : cmd.split(" "), {
|
||||||
|
...opts,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read from stdout
|
||||||
|
const reader = proc.stdout.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
// Function to read chunks
|
||||||
|
let resolveCalled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const text = decoder.decode(value);
|
||||||
|
if (!resolveCalled) {
|
||||||
|
console.log(c.dim(text.replace(/\n$/, "")));
|
||||||
|
}
|
||||||
|
onChunk(
|
||||||
|
text,
|
||||||
|
(data) => {
|
||||||
|
resolve({ proc, data });
|
||||||
|
resolveCalled = true;
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
proc.exited.then((code) => {
|
||||||
|
if (code !== 0 && code !== 130) {
|
||||||
|
throw new Error(`Process exited with code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapters = {
|
||||||
|
node: {
|
||||||
|
dir: path.join(basePath, "examples/node"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run start",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /running on (http:\/\/.*)\n/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bun: {
|
||||||
|
dir: path.join(basePath, "examples/bun"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf uploads data.db && mkdir -p uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run start",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /running on (http:\/\/.*)\n/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cloudflare: {
|
||||||
|
dir: path.join(basePath, "examples/cloudflare-worker"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .wrangler node_modules/.cache node_modules/.mf`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Ready on (http:\/\/.*)/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"react-router": {
|
||||||
|
dir: path.join(basePath, "examples/react-router"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .react-router data.db`;
|
||||||
|
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Local.*?(http:\/\/.*)\//;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nextjs: {
|
||||||
|
dir: path.join(basePath, "examples/nextjs"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .nextjs data.db`;
|
||||||
|
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Local.*?(http:\/\/.*)\n/;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
astro: {
|
||||||
|
dir: path.join(basePath, "examples/astro"),
|
||||||
|
clean: async function () {
|
||||||
|
const cwd = path.relative(process.cwd(), this.dir);
|
||||||
|
await $`cd ${cwd} && rm -rf .astro data.db`;
|
||||||
|
await $`cd ${cwd} && rm -rf public/uploads && mkdir -p public/uploads`;
|
||||||
|
},
|
||||||
|
start: async function () {
|
||||||
|
return await run(
|
||||||
|
"npm run dev",
|
||||||
|
{
|
||||||
|
cwd: this.dir,
|
||||||
|
},
|
||||||
|
(chunk, resolve, reject) => {
|
||||||
|
const regex = /Local.*?(http:\/\/.*)\//;
|
||||||
|
if (regex.test(chunk)) {
|
||||||
|
resolve(chunk.match(regex)?.[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function testAdapter(name: keyof typeof adapters) {
|
||||||
|
const config = adapters[name];
|
||||||
|
console.log("adapter", c.cyan(name));
|
||||||
|
await config.clean();
|
||||||
|
|
||||||
|
const { proc, data } = await config.start();
|
||||||
|
console.log("proc:", proc.pid, "data:", c.cyan(data));
|
||||||
|
//proc.kill();process.exit(0);
|
||||||
|
|
||||||
|
await $`TEST_URL=${data} TEST_ADAPTER=${name} bun run test:e2e`;
|
||||||
|
console.log("DONE!");
|
||||||
|
|
||||||
|
while (!proc.killed) {
|
||||||
|
proc.kill("SIGINT");
|
||||||
|
await Bun.sleep(250);
|
||||||
|
console.log("Waiting for process to exit...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.TEST_ADAPTER) {
|
||||||
|
await testAdapter(process.env.TEST_ADAPTER as any);
|
||||||
|
} else {
|
||||||
|
for (const [name] of Object.entries(adapters)) {
|
||||||
|
await testAdapter(name as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { testIds } from "../src/ui/lib/config";
|
||||||
|
|
||||||
|
import { getAdapterConfig } from "./inc/adapters";
|
||||||
|
const config = getAdapterConfig();
|
||||||
|
|
||||||
|
test("start page has expected title", async ({ page }) => {
|
||||||
|
await page.goto(config.base_path);
|
||||||
|
await expect(page).toHaveTitle(/BKND/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start page has expected heading", async ({ page }) => {
|
||||||
|
await page.goto(config.base_path);
|
||||||
|
|
||||||
|
// Example of checking if a heading with "No entity selected" exists and is visible
|
||||||
|
const heading = page.getByRole("heading", { name: /No entity selected/i });
|
||||||
|
await expect(heading).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("modal opens on button click", async ({ page }) => {
|
||||||
|
await page.goto(config.base_path);
|
||||||
|
await page.getByTestId(testIds.data.btnCreateEntity).click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const adapter = process.env.TEST_ADAPTER;
|
||||||
|
|
||||||
|
const default_config = {
|
||||||
|
media_adapter: "local",
|
||||||
|
base_path: "",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const configs = {
|
||||||
|
cloudflare: {
|
||||||
|
media_adapter: "r2",
|
||||||
|
},
|
||||||
|
"react-router": {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
nextjs: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
astro: {
|
||||||
|
base_path: "/admin",
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
base_path: "",
|
||||||
|
},
|
||||||
|
bun: {
|
||||||
|
base_path: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAdapterConfig(): typeof default_config {
|
||||||
|
if (adapter) {
|
||||||
|
if (!configs[adapter]) {
|
||||||
|
console.warn(
|
||||||
|
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
...default_config,
|
||||||
|
...configs[adapter],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return default_config;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { testIds } from "../src/ui/lib/config";
|
||||||
|
import type { SchemaResponse } from "../src/modules/server/SystemController";
|
||||||
|
import { getAdapterConfig } from "./inc/adapters";
|
||||||
|
|
||||||
|
// Annotate entire file as serial.
|
||||||
|
test.describe.configure({ mode: "serial" });
|
||||||
|
|
||||||
|
const config = getAdapterConfig();
|
||||||
|
|
||||||
|
test("can enable media", async ({ page }) => {
|
||||||
|
await page.goto(`${config.base_path}/media/settings`);
|
||||||
|
|
||||||
|
// enable
|
||||||
|
const enableToggle = page.getByTestId(testIds.media.switchEnabled);
|
||||||
|
if ((await enableToggle.getAttribute("aria-checked")) !== "true") {
|
||||||
|
await expect(enableToggle).toBeVisible();
|
||||||
|
await enableToggle.click();
|
||||||
|
await expect(enableToggle).toHaveAttribute("aria-checked", "true");
|
||||||
|
|
||||||
|
// select local
|
||||||
|
const adapterChoice = page.locator(`css=button#adapter-${config.media_adapter}`);
|
||||||
|
await expect(adapterChoice).toBeVisible();
|
||||||
|
await adapterChoice.click();
|
||||||
|
|
||||||
|
// save
|
||||||
|
const saveBtn = page.getByRole("button", { name: /Update/i });
|
||||||
|
await expect(saveBtn).toBeVisible();
|
||||||
|
|
||||||
|
// intercept network request, wait for it to finish and get the response
|
||||||
|
const [request] = await Promise.all([
|
||||||
|
page.waitForRequest((request) => request.url().includes("api/system/schema")),
|
||||||
|
saveBtn.click(),
|
||||||
|
]);
|
||||||
|
const response = await request.response();
|
||||||
|
expect(response?.status(), "fresh config 200").toBe(200);
|
||||||
|
const body = (await response?.json()) as SchemaResponse;
|
||||||
|
expect(body.config.media.enabled, "media is enabled").toBe(true);
|
||||||
|
expect(body.config.media.adapter?.type, "correct adapter").toBe(config.media_adapter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("can upload a file", async ({ page }) => {
|
||||||
|
await page.goto(`${config.base_path}/media`);
|
||||||
|
// check any text to contain "Upload files"
|
||||||
|
await expect(page.getByText(/Upload files/i)).toBeVisible();
|
||||||
|
|
||||||
|
// upload a file from disk
|
||||||
|
// Start waiting for file chooser before clicking. Note no await.
|
||||||
|
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||||
|
await page.getByText("Upload file").click();
|
||||||
|
const fileChooser = await fileChooserPromise;
|
||||||
|
await fileChooser.setFiles("./e2e/assets/image.jpg");
|
||||||
|
});
|
||||||
+55
-29
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.10.0",
|
"version": "0.13.0",
|
||||||
"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": {
|
||||||
@@ -14,12 +14,11 @@
|
|||||||
"url": "https://github.com/bknd-io/bknd/issues"
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "BKND_CLI_LOG_LEVEL=debug vite",
|
||||||
"test": "ALL_TESTS=1 bun test --bail",
|
|
||||||
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
|
||||||
"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:cli": "bun build src/cli/index.ts --target node --outdir dist/cli --env PUBLIC_* --minify",
|
"build:ci": "mkdir -p dist/static/.vite && echo '{}' > dist/static/.vite/manifest.json && NODE_ENV=production bun run build.ts",
|
||||||
|
"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",
|
||||||
@@ -27,36 +26,49 @@
|
|||||||
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
"build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly && tsc-alias",
|
||||||
"updater": "bun x npm-check-updates -ui",
|
"updater": "bun x npm-check-updates -ui",
|
||||||
"cli": "LOCAL=1 bun src/cli/index.ts",
|
"cli": "LOCAL=1 bun src/cli/index.ts",
|
||||||
"prepublishOnly": "bun run types && bun run test && bun run build:all && cp ../README.md ./",
|
"prepublishOnly": "bun run types && bun run test && bun run test:node && bun run test:e2e && bun run build:all && cp ../README.md ./",
|
||||||
"postpublish": "rm -f README.md"
|
"postpublish": "rm -f README.md",
|
||||||
|
"test": "ALL_TESTS=1 bun test --bail",
|
||||||
|
"test:all": "bun run test && bun run test:node",
|
||||||
|
"test:bun": "ALL_TESTS=1 bun test --bail",
|
||||||
|
"test:node": "tsx --test $(find . -type f -name '*.native-spec.ts')",
|
||||||
|
"test:adapters": "bun test src/adapter/**/*.adapter.spec.ts --bail",
|
||||||
|
"test:coverage": "ALL_TESTS=1 bun test --bail --coverage",
|
||||||
|
"test:vitest": "vitest run",
|
||||||
|
"test:vitest:watch": "vitest",
|
||||||
|
"test:vitest:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:adapters": "bun run e2e/adapters.ts",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:debug": "playwright test --debug",
|
||||||
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"license": "FSL-1.1-MIT",
|
"license": "FSL-1.1-MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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.14.0",
|
"@hono/swagger-ui": "^0.5.1",
|
||||||
|
"@libsql/client": "^0.15.2",
|
||||||
"@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.4",
|
||||||
"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",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -70,21 +82,28 @@
|
|||||||
"@libsql/kysely-libsql": "^0.4.1",
|
"@libsql/kysely-libsql": "^0.4.1",
|
||||||
"@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",
|
||||||
"@rjsf/core": "5.22.2",
|
"@rjsf/core": "5.22.2",
|
||||||
"@tabler/icons-react": "3.18.0",
|
"@tabler/icons-react": "3.18.0",
|
||||||
"@tailwindcss/postcss": "^4.0.12",
|
"@tailwindcss/postcss": "^4.0.12",
|
||||||
"@tailwindcss/vite": "^4.0.12",
|
"@tailwindcss/vite": "^4.0.12",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.10",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^3.0.9",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
|
"jsonv-ts": "^0.0.14-alpha.6",
|
||||||
"kysely-d1": "^0.3.0",
|
"kysely-d1": "^0.3.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",
|
||||||
@@ -100,8 +119,10 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tsc-alias": "^1.8.11",
|
"tsc-alias": "^1.8.11",
|
||||||
"tsup": "^8.4.0",
|
"tsup": "^8.4.0",
|
||||||
|
"tsx": "^4.19.3",
|
||||||
"vite": "^6.2.1",
|
"vite": "^6.2.1",
|
||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^3.0.9",
|
||||||
"wouter": "^3.6.0"
|
"wouter": "^3.6.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
@@ -118,47 +139,52 @@
|
|||||||
".": {
|
".": {
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"require": "./dist/index.cjs"
|
"require": "./dist/index.js"
|
||||||
},
|
},
|
||||||
"./ui": {
|
"./ui": {
|
||||||
"types": "./dist/types/ui/index.d.ts",
|
"types": "./dist/types/ui/index.d.ts",
|
||||||
"import": "./dist/ui/index.js",
|
"import": "./dist/ui/index.js",
|
||||||
"require": "./dist/ui/index.cjs"
|
"require": "./dist/ui/index.js"
|
||||||
},
|
},
|
||||||
"./elements": {
|
"./elements": {
|
||||||
"types": "./dist/types/ui/elements/index.d.ts",
|
"types": "./dist/types/ui/elements/index.d.ts",
|
||||||
"import": "./dist/ui/elements/index.js",
|
"import": "./dist/ui/elements/index.js",
|
||||||
"require": "./dist/ui/elements/index.cjs"
|
"require": "./dist/ui/elements/index.js"
|
||||||
},
|
},
|
||||||
"./client": {
|
"./client": {
|
||||||
"types": "./dist/types/ui/client/index.d.ts",
|
"types": "./dist/types/ui/client/index.d.ts",
|
||||||
"import": "./dist/ui/client/index.js",
|
"import": "./dist/ui/client/index.js",
|
||||||
"require": "./dist/ui/client/index.cjs"
|
"require": "./dist/ui/client/index.js"
|
||||||
},
|
},
|
||||||
"./data": {
|
"./data": {
|
||||||
"types": "./dist/types/data/index.d.ts",
|
"types": "./dist/types/data/index.d.ts",
|
||||||
"import": "./dist/data/index.js",
|
"import": "./dist/data/index.js",
|
||||||
"require": "./dist/data/index.cjs"
|
"require": "./dist/data/index.js"
|
||||||
},
|
},
|
||||||
"./core": {
|
"./core": {
|
||||||
"types": "./dist/types/core/index.d.ts",
|
"types": "./dist/types/core/index.d.ts",
|
||||||
"import": "./dist/core/index.js",
|
"import": "./dist/core/index.js",
|
||||||
"require": "./dist/core/index.cjs"
|
"require": "./dist/core/index.js"
|
||||||
},
|
},
|
||||||
"./utils": {
|
"./utils": {
|
||||||
"types": "./dist/types/core/utils/index.d.ts",
|
"types": "./dist/types/core/utils/index.d.ts",
|
||||||
"import": "./dist/core/utils/index.js",
|
"import": "./dist/core/utils/index.js",
|
||||||
"require": "./dist/core/utils/index.cjs"
|
"require": "./dist/core/utils/index.js"
|
||||||
},
|
},
|
||||||
"./cli": {
|
"./cli": {
|
||||||
"types": "./dist/types/cli/index.d.ts",
|
"types": "./dist/types/cli/index.d.ts",
|
||||||
"import": "./dist/cli/index.js",
|
"import": "./dist/cli/index.js",
|
||||||
"require": "./dist/cli/index.cjs"
|
"require": "./dist/cli/index.js"
|
||||||
|
},
|
||||||
|
"./media": {
|
||||||
|
"types": "./dist/types/media/index.d.ts",
|
||||||
|
"import": "./dist/media/index.js",
|
||||||
|
"require": "./dist/media/index.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",
|
||||||
"require": "./dist/adapter/cloudflare/index.cjs"
|
"require": "./dist/adapter/cloudflare/index.js"
|
||||||
},
|
},
|
||||||
"./adapter": {
|
"./adapter": {
|
||||||
"types": "./dist/types/adapter/index.d.ts",
|
"types": "./dist/types/adapter/index.d.ts",
|
||||||
@@ -167,37 +193,37 @@
|
|||||||
"./adapter/vite": {
|
"./adapter/vite": {
|
||||||
"types": "./dist/types/adapter/vite/index.d.ts",
|
"types": "./dist/types/adapter/vite/index.d.ts",
|
||||||
"import": "./dist/adapter/vite/index.js",
|
"import": "./dist/adapter/vite/index.js",
|
||||||
"require": "./dist/adapter/vite/index.cjs"
|
"require": "./dist/adapter/vite/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/nextjs": {
|
"./adapter/nextjs": {
|
||||||
"types": "./dist/types/adapter/nextjs/index.d.ts",
|
"types": "./dist/types/adapter/nextjs/index.d.ts",
|
||||||
"import": "./dist/adapter/nextjs/index.js",
|
"import": "./dist/adapter/nextjs/index.js",
|
||||||
"require": "./dist/adapter/nextjs/index.cjs"
|
"require": "./dist/adapter/nextjs/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/react-router": {
|
"./adapter/react-router": {
|
||||||
"types": "./dist/types/adapter/react-router/index.d.ts",
|
"types": "./dist/types/adapter/react-router/index.d.ts",
|
||||||
"import": "./dist/adapter/react-router/index.js",
|
"import": "./dist/adapter/react-router/index.js",
|
||||||
"require": "./dist/adapter/react-router/index.cjs"
|
"require": "./dist/adapter/react-router/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/bun": {
|
"./adapter/bun": {
|
||||||
"types": "./dist/types/adapter/bun/index.d.ts",
|
"types": "./dist/types/adapter/bun/index.d.ts",
|
||||||
"import": "./dist/adapter/bun/index.js",
|
"import": "./dist/adapter/bun/index.js",
|
||||||
"require": "./dist/adapter/bun/index.cjs"
|
"require": "./dist/adapter/bun/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/node": {
|
"./adapter/node": {
|
||||||
"types": "./dist/types/adapter/node/index.d.ts",
|
"types": "./dist/types/adapter/node/index.d.ts",
|
||||||
"import": "./dist/adapter/node/index.js",
|
"import": "./dist/adapter/node/index.js",
|
||||||
"require": "./dist/adapter/node/index.cjs"
|
"require": "./dist/adapter/node/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/astro": {
|
"./adapter/astro": {
|
||||||
"types": "./dist/types/adapter/astro/index.d.ts",
|
"types": "./dist/types/adapter/astro/index.d.ts",
|
||||||
"import": "./dist/adapter/astro/index.js",
|
"import": "./dist/adapter/astro/index.js",
|
||||||
"require": "./dist/adapter/astro/index.cjs"
|
"require": "./dist/adapter/astro/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/aws": {
|
"./adapter/aws": {
|
||||||
"types": "./dist/types/adapter/aws/index.d.ts",
|
"types": "./dist/types/adapter/aws/index.d.ts",
|
||||||
"import": "./dist/adapter/aws/index.js",
|
"import": "./dist/adapter/aws/index.js",
|
||||||
"require": "./dist/adapter/aws/index.cjs"
|
"require": "./dist/adapter/aws/index.js"
|
||||||
},
|
},
|
||||||
"./dist/main.css": "./dist/ui/main.css",
|
"./dist/main.css": "./dist/ui/main.css",
|
||||||
"./dist/styles.css": "./dist/ui/styles.css",
|
"./dist/styles.css": "./dist/ui/styles.css",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
|
||||||
|
const baseUrl = process.env.TEST_URL || "http://localhost:28623";
|
||||||
|
const startCommand = process.env.TEST_START_COMMAND || "bun run dev";
|
||||||
|
const autoStart = ["1", "true", undefined].includes(process.env.TEST_AUTO_START);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testMatch: "**/*.e2e-spec.ts",
|
||||||
|
testDir: "./e2e",
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: "html",
|
||||||
|
timeout: 20000,
|
||||||
|
use: {
|
||||||
|
baseURL: baseUrl,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
video: "on-first-retry",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
/* {
|
||||||
|
name: "firefox",
|
||||||
|
use: { ...devices["Desktop Firefox"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: { ...devices["Desktop Safari"] },
|
||||||
|
}, */
|
||||||
|
],
|
||||||
|
webServer: autoStart
|
||||||
|
? {
|
||||||
|
command: startCommand,
|
||||||
|
url: baseUrl,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
+82
-25
@@ -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;
|
||||||
@@ -78,6 +94,10 @@ export class Api {
|
|||||||
this.buildApis();
|
this.buildApis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get fetcher() {
|
||||||
|
return this.options.fetcher ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.options.host ?? "http://localhost";
|
return this.options.host ?? "http://localhost";
|
||||||
}
|
}
|
||||||
@@ -106,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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateToken(token?: string, rebuild?: boolean) {
|
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, opts?: { rebuild?: boolean; trigger?: boolean }) {
|
||||||
this.token = token;
|
this.token = token;
|
||||||
this.verified = false;
|
this.verified = false;
|
||||||
|
|
||||||
@@ -127,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) {
|
||||||
@@ -207,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-36
@@ -4,9 +4,10 @@ import { Event } from "core/events";
|
|||||||
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
import { Connection, type LibSqlCredentials, LibsqlConnection } from "data";
|
||||||
import type { Hono } from "hono";
|
import type { Hono } from "hono";
|
||||||
import {
|
import {
|
||||||
|
ModuleManager,
|
||||||
type InitialModuleConfigs,
|
type InitialModuleConfigs,
|
||||||
type ModuleBuildContext,
|
type ModuleBuildContext,
|
||||||
ModuleManager,
|
type ModuleConfigs,
|
||||||
type ModuleManagerOptions,
|
type ModuleManagerOptions,
|
||||||
type Modules,
|
type Modules,
|
||||||
} from "modules/ModuleManager";
|
} from "modules/ModuleManager";
|
||||||
@@ -14,8 +15,9 @@ 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";
|
||||||
|
|
||||||
// biome-ignore format: must be there
|
// biome-ignore format: must be here
|
||||||
import { Api, type ApiOptions } from "Api";
|
import { Api, type ApiOptions } from "Api";
|
||||||
|
import type { ServerEnv } from "modules/Controller";
|
||||||
|
|
||||||
export type AppPlugin = (app: App) => Promise<void> | void;
|
export type AppPlugin = (app: App) => Promise<void> | void;
|
||||||
|
|
||||||
@@ -29,12 +31,25 @@ export class AppBuiltEvent extends AppEvent {
|
|||||||
export class AppFirstBoot extends AppEvent {
|
export class AppFirstBoot extends AppEvent {
|
||||||
static override slug = "app-first-boot";
|
static override slug = "app-first-boot";
|
||||||
}
|
}
|
||||||
export const AppEvents = { AppConfigUpdatedEvent, AppBuiltEvent, AppFirstBoot } as const;
|
export class AppRequest extends AppEvent<{ request: Request }> {
|
||||||
|
static override slug = "app-request";
|
||||||
|
}
|
||||||
|
export class AppBeforeResponse extends AppEvent<{ request: Request; response: Response }> {
|
||||||
|
static override slug = "app-before-response";
|
||||||
|
}
|
||||||
|
export const AppEvents = {
|
||||||
|
AppConfigUpdatedEvent,
|
||||||
|
AppBuiltEvent,
|
||||||
|
AppFirstBoot,
|
||||||
|
AppRequest,
|
||||||
|
AppBeforeResponse,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type AppOptions = {
|
export type AppOptions = {
|
||||||
plugins?: AppPlugin[];
|
plugins?: AppPlugin[];
|
||||||
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";
|
||||||
};
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
connection?:
|
connection?:
|
||||||
@@ -53,12 +68,14 @@ export type AppConfig = InitialModuleConfigs;
|
|||||||
export type LocalApiOptions = Request | ApiOptions;
|
export type LocalApiOptions = Request | ApiOptions;
|
||||||
|
|
||||||
export class App {
|
export class App {
|
||||||
modules: ModuleManager;
|
|
||||||
static readonly Events = AppEvents;
|
static readonly Events = AppEvents;
|
||||||
|
|
||||||
|
modules: ModuleManager;
|
||||||
adminController?: AdminController;
|
adminController?: AdminController;
|
||||||
|
_id: string = crypto.randomUUID();
|
||||||
|
|
||||||
private trigger_first_boot = false;
|
private trigger_first_boot = false;
|
||||||
private plugins: AppPlugin[];
|
private plugins: AppPlugin[];
|
||||||
private _id: string = crypto.randomUUID();
|
|
||||||
private _building: boolean = false;
|
private _building: boolean = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -70,35 +87,9 @@ export class App {
|
|||||||
this.modules = new ModuleManager(connection, {
|
this.modules = new ModuleManager(connection, {
|
||||||
...(options?.manager ?? {}),
|
...(options?.manager ?? {}),
|
||||||
initial: _initialConfig,
|
initial: _initialConfig,
|
||||||
onUpdated: async (key, config) => {
|
onUpdated: this.onUpdated.bind(this),
|
||||||
// if the EventManager was disabled, we assume we shouldn't
|
onFirstBoot: this.onFirstBoot.bind(this),
|
||||||
// respond to events, such as "onUpdated".
|
onServerInit: this.onServerInit.bind(this),
|
||||||
// this is important if multiple changes are done, and then build() is called manually
|
|
||||||
if (!this.emgr.enabled) {
|
|
||||||
$console.warn("App config updated, but event manager is disabled, skip.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$console.log("App config updated", key);
|
|
||||||
// @todo: potentially double syncing
|
|
||||||
await this.build({ sync: true });
|
|
||||||
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
|
||||||
},
|
|
||||||
onFirstBoot: async () => {
|
|
||||||
$console.log("App first boot");
|
|
||||||
this.trigger_first_boot = true;
|
|
||||||
},
|
|
||||||
onServerInit: async (server) => {
|
|
||||||
server.use(async (c, next) => {
|
|
||||||
c.set("app", this);
|
|
||||||
await next();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// gracefully add the app id
|
|
||||||
c.res.headers.set("X-bknd-id", this._id);
|
|
||||||
} catch (e) {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
this.modules.ctx().emgr.registerEvents(AppEvents);
|
this.modules.ctx().emgr.registerEvents(AppEvents);
|
||||||
}
|
}
|
||||||
@@ -160,7 +151,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() {
|
||||||
@@ -189,7 +180,10 @@ export class App {
|
|||||||
registerAdminController(config?: AdminControllerOptions) {
|
registerAdminController(config?: AdminControllerOptions) {
|
||||||
// register admin
|
// register admin
|
||||||
this.adminController = new AdminController(this, config);
|
this.adminController = new AdminController(this, config);
|
||||||
this.modules.server.route(config?.basepath ?? "/", this.adminController.getController());
|
this.modules.server.route(
|
||||||
|
this.adminController.basepath,
|
||||||
|
this.adminController.getController(),
|
||||||
|
);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +207,53 @@ export class App {
|
|||||||
|
|
||||||
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
|
return new Api({ host: "http://localhost", ...(options ?? {}), fetcher });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onUpdated<Module extends keyof Modules>(module: Module, config: ModuleConfigs[Module]) {
|
||||||
|
// if the EventManager was disabled, we assume we shouldn't
|
||||||
|
// respond to events, such as "onUpdated".
|
||||||
|
// this is important if multiple changes are done, and then build() is called manually
|
||||||
|
if (!this.emgr.enabled) {
|
||||||
|
$console.warn("App config updated, but event manager is disabled, skip.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$console.log("App config updated", module);
|
||||||
|
// @todo: potentially double syncing
|
||||||
|
await this.build({ sync: true });
|
||||||
|
await this.emgr.emit(new AppConfigUpdatedEvent({ app: this }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFirstBoot() {
|
||||||
|
$console.log("App first boot");
|
||||||
|
this.trigger_first_boot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onServerInit(server: Hono<ServerEnv>) {
|
||||||
|
server.use(async (c, next) => {
|
||||||
|
c.set("app", this);
|
||||||
|
await this.emgr.emit(new AppRequest({ app: this, request: c.req.raw }));
|
||||||
|
await next();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// gracefully add the app id
|
||||||
|
c.res.headers.set("X-bknd-id", this._id);
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
await this.emgr.emit(
|
||||||
|
new AppBeforeResponse({ app: this, request: c.req.raw, response: c.res }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// execute collected async events (async by default)
|
||||||
|
switch (this.options?.asyncEventsMode ?? "async") {
|
||||||
|
case "sync":
|
||||||
|
await this.emgr.executeAsyncs();
|
||||||
|
break;
|
||||||
|
case "async":
|
||||||
|
this.emgr.executeAsyncs();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(config: CreateAppConfig = {}) {
|
export function createApp(config: CreateAppConfig = {}) {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { TestRunner } from "core/test";
|
||||||
|
import type { BkndConfig, DefaultArgs, FrameworkOptions, RuntimeOptions } from "./index";
|
||||||
|
import type { App } from "App";
|
||||||
|
|
||||||
|
export function adapterTestSuite<
|
||||||
|
Config extends BkndConfig = BkndConfig,
|
||||||
|
Args extends DefaultArgs = DefaultArgs,
|
||||||
|
>(
|
||||||
|
testRunner: TestRunner,
|
||||||
|
{
|
||||||
|
makeApp,
|
||||||
|
makeHandler,
|
||||||
|
label = "app",
|
||||||
|
overrides = {},
|
||||||
|
}: {
|
||||||
|
makeApp: (
|
||||||
|
config: Config,
|
||||||
|
args?: Args,
|
||||||
|
opts?: RuntimeOptions | FrameworkOptions,
|
||||||
|
) => Promise<App>;
|
||||||
|
makeHandler?: (
|
||||||
|
config?: Config,
|
||||||
|
args?: Args,
|
||||||
|
opts?: RuntimeOptions | FrameworkOptions,
|
||||||
|
) => (request: Request) => Promise<Response>;
|
||||||
|
label?: string;
|
||||||
|
overrides?: {
|
||||||
|
dbUrl?: string;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { test, expect, mock } = testRunner;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
test(`creates ${label}`, async () => {
|
||||||
|
const beforeBuild = mock(async () => null) as any;
|
||||||
|
const onBuilt = mock(async () => null) as any;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
app: (env) => ({
|
||||||
|
connection: { url: env.url },
|
||||||
|
initialConfig: {
|
||||||
|
server: { cors: { origin: env.origin } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
beforeBuild,
|
||||||
|
onBuilt,
|
||||||
|
} as const satisfies BkndConfig;
|
||||||
|
|
||||||
|
const app = await makeApp(
|
||||||
|
config as any,
|
||||||
|
{
|
||||||
|
url: overrides.dbUrl ?? ":memory:",
|
||||||
|
origin: "localhost",
|
||||||
|
} as any,
|
||||||
|
{ id },
|
||||||
|
);
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
expect(app.toJSON().server.cors.origin).toEqual("localhost");
|
||||||
|
expect(beforeBuild).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onBuilt).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (makeHandler) {
|
||||||
|
const getConfig = async (fetcher: (r: Request) => Promise<Response>) => {
|
||||||
|
const res = await fetcher(new Request("http://localhost:3000/api/system/config"));
|
||||||
|
const data = (await res.json()) as any;
|
||||||
|
return { res, data };
|
||||||
|
};
|
||||||
|
|
||||||
|
test("responds with the same app id", async () => {
|
||||||
|
const fetcher = makeHandler(undefined, undefined, { id });
|
||||||
|
|
||||||
|
const { res, data } = await getConfig(fetcher);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(data.server.cors.origin).toEqual("localhost");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates fresh & responds to api config", async () => {
|
||||||
|
// set the same id, but force recreate
|
||||||
|
const fetcher = makeHandler(undefined, undefined, { id, force: true });
|
||||||
|
|
||||||
|
const { res, data } = await getConfig(fetcher);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(data.server.cors.origin).toEqual("*");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as astro from "./astro.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("astro adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: astro.getApp,
|
||||||
|
makeHandler: (c, a, o) => (request: Request) => astro.serve(c, a, o)({ request }),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,25 @@
|
|||||||
import type { App } from "bknd";
|
import { type FrameworkBkndConfig, createFrameworkApp, type FrameworkOptions } from "bknd/adapter";
|
||||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
|
||||||
import { Api, type ApiOptions } from "bknd/client";
|
|
||||||
|
|
||||||
export type AstroBkndConfig<Args = TAstro> = FrameworkBkndConfig<Args>;
|
|
||||||
|
|
||||||
|
type AstroEnv = NodeJS.ProcessEnv;
|
||||||
type TAstro = {
|
type TAstro = {
|
||||||
request: Request;
|
request: Request;
|
||||||
};
|
};
|
||||||
|
export type AstroBkndConfig<Env = AstroEnv> = FrameworkBkndConfig<Env>;
|
||||||
|
|
||||||
export type Options = {
|
export async function getApp<Env = AstroEnv>(
|
||||||
mode?: "static" | "dynamic";
|
config: AstroBkndConfig<Env> = {},
|
||||||
} & Omit<ApiOptions, "host"> & {
|
args: Env = {} as Env,
|
||||||
host?: string;
|
opts: FrameworkOptions = {},
|
||||||
};
|
) {
|
||||||
|
return await createFrameworkApp(config, args ?? import.meta.env, opts);
|
||||||
export async function getApi(Astro: TAstro, options: Options = { mode: "static" }) {
|
|
||||||
const api = new Api({
|
|
||||||
host: new URL(Astro.request.url).origin,
|
|
||||||
headers: options.mode === "dynamic" ? Astro.request.headers : undefined,
|
|
||||||
});
|
|
||||||
await api.verifyAuth();
|
|
||||||
return api;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
export function serve<Env = AstroEnv>(
|
||||||
export function serve<Context extends TAstro = TAstro>(config: AstroBkndConfig<Context> = {}) {
|
config: AstroBkndConfig<Env> = {},
|
||||||
return async (args: Context) => {
|
args: Env = {} as Env,
|
||||||
if (!app) {
|
opts?: FrameworkOptions,
|
||||||
app = await createFrameworkApp(config, args);
|
) {
|
||||||
}
|
return async (fnArgs: TAstro) => {
|
||||||
return app.fetch(args.request);
|
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,76 @@
|
|||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { handle } from "hono/aws-lambda";
|
import { handle } from "hono/aws-lambda";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
|
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
|
|
||||||
export type AwsLambdaBkndConfig = RuntimeBkndConfig & {
|
type AwsLambdaEnv = object;
|
||||||
assets?:
|
export type AwsLambdaBkndConfig<Env extends AwsLambdaEnv = AwsLambdaEnv> =
|
||||||
| {
|
RuntimeBkndConfig<Env> & {
|
||||||
mode: "local";
|
assets?:
|
||||||
root: string;
|
| {
|
||||||
}
|
mode: "local";
|
||||||
| {
|
root: string;
|
||||||
mode: "url";
|
}
|
||||||
url: string;
|
| {
|
||||||
};
|
mode: "url";
|
||||||
};
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
let app: App;
|
export async function createApp<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||||
export async function createApp({
|
{ adminOptions = false, assets, ...config }: AwsLambdaBkndConfig<Env> = {},
|
||||||
adminOptions = false,
|
args: Env = {} as Env,
|
||||||
assets,
|
opts?: RuntimeOptions,
|
||||||
...config
|
): Promise<App> {
|
||||||
}: AwsLambdaBkndConfig = {}) {
|
let additional: Partial<RuntimeBkndConfig> = {
|
||||||
if (!app) {
|
adminOptions,
|
||||||
let additional: Partial<RuntimeBkndConfig> = {
|
};
|
||||||
adminOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (assets?.mode) {
|
if (assets?.mode) {
|
||||||
switch (assets.mode) {
|
switch (assets.mode) {
|
||||||
case "local":
|
case "local":
|
||||||
// @todo: serve static outside app context
|
// @todo: serve static outside app context
|
||||||
additional = {
|
additional = {
|
||||||
adminOptions: adminOptions === false ? undefined : adminOptions,
|
adminOptions: adminOptions === false ? undefined : adminOptions,
|
||||||
serveStatic: (await import("@hono/node-server/serve-static")).serveStatic({
|
serveStatic: serveStatic({
|
||||||
root: assets.root,
|
root: assets.root,
|
||||||
onFound: (path, c) => {
|
onFound: (path, c) => {
|
||||||
c.res.headers.set("Cache-Control", "public, max-age=31536000");
|
c.res.headers.set("Cache-Control", "public, max-age=31536000");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "url":
|
case "url":
|
||||||
additional.adminOptions = {
|
additional.adminOptions = {
|
||||||
...(typeof adminOptions === "object" ? adminOptions : {}),
|
...(typeof adminOptions === "object" ? adminOptions : {}),
|
||||||
assets_path: assets.url,
|
assetsPath: assets.url,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error("Invalid assets mode");
|
throw new Error("Invalid assets mode");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app = await createRuntimeApp({
|
|
||||||
...config,
|
|
||||||
...additional,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return await createRuntimeApp(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
...additional,
|
||||||
|
},
|
||||||
|
args ?? process.env,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serveLambda(config: AwsLambdaBkndConfig = {}) {
|
export function serve<Env extends AwsLambdaEnv = AwsLambdaEnv>(
|
||||||
console.log("serving lambda");
|
config: AwsLambdaBkndConfig<Env> = {},
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
) {
|
||||||
return async (event) => {
|
return async (event) => {
|
||||||
const app = await createApp(config);
|
const app = await createApp(config, args, opts);
|
||||||
return await handle(app.server)(event);
|
return await handle(app.server)(event);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compatibility with old code
|
||||||
|
export const serveLambda = serve;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as awsLambda from "./aws-lambda.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("aws adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: awsLambda.createApp,
|
||||||
|
// @todo: add a request to lambda event translator?
|
||||||
|
makeHandler: (c, a, o) => async (request: Request) => {
|
||||||
|
const app = await awsLambda.createApp(c, a, o);
|
||||||
|
return app.fetch(request);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as bun from "./bun.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("bun adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: bun.createApp,
|
||||||
|
makeHandler: bun.createHandler,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,47 +1,64 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { App } from "bknd";
|
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
|
||||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||||
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";
|
||||||
|
|
||||||
let app: App;
|
type BunEnv = Bun.Env;
|
||||||
|
export type BunBkndConfig<Env = BunEnv> = RuntimeBkndConfig<Env> & Omit<ServeOptions, "fetch">;
|
||||||
|
|
||||||
export type BunBkndConfig = RuntimeBkndConfig & Omit<ServeOptions, "fetch">;
|
export async function createApp<Env = BunEnv>(
|
||||||
|
{ distPath, ...config }: BunBkndConfig<Env> = {},
|
||||||
export async function createApp({ distPath, ...config }: RuntimeBkndConfig = {}) {
|
args: Env = {} as Env,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
) {
|
||||||
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
const root = path.resolve(distPath ?? "./node_modules/bknd/dist", "static");
|
||||||
|
registerLocalMediaAdapter();
|
||||||
|
|
||||||
if (!app) {
|
return await createRuntimeApp(
|
||||||
registerLocalMediaAdapter();
|
{
|
||||||
app = await createRuntimeApp({
|
|
||||||
...config,
|
...config,
|
||||||
serveStatic: serveStatic({ root }),
|
serveStatic: serveStatic({ root }),
|
||||||
});
|
},
|
||||||
}
|
args ?? (process.env as Env),
|
||||||
|
opts,
|
||||||
return app;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve({
|
export function createHandler<Env = BunEnv>(
|
||||||
distPath,
|
config: BunBkndConfig<Env> = {},
|
||||||
connection,
|
args: Env = {} as Env,
|
||||||
initialConfig,
|
opts?: RuntimeOptions,
|
||||||
options,
|
) {
|
||||||
port = config.server.default_port,
|
return async (req: Request) => {
|
||||||
onBuilt,
|
const app = await createApp(config, args ?? (process.env as Env), opts);
|
||||||
buildConfig,
|
return app.fetch(req);
|
||||||
adminOptions,
|
};
|
||||||
...serveOptions
|
}
|
||||||
}: BunBkndConfig = {}) {
|
|
||||||
|
export function serve<Env = BunEnv>(
|
||||||
|
{
|
||||||
|
distPath,
|
||||||
|
connection,
|
||||||
|
initialConfig,
|
||||||
|
options,
|
||||||
|
port = config.server.default_port,
|
||||||
|
onBuilt,
|
||||||
|
buildConfig,
|
||||||
|
adminOptions,
|
||||||
|
...serveOptions
|
||||||
|
}: BunBkndConfig<Env> = {},
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
) {
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
...serveOptions,
|
...serveOptions,
|
||||||
port,
|
port,
|
||||||
fetch: async (request: Request) => {
|
fetch: createHandler(
|
||||||
const app = await createApp({
|
{
|
||||||
connection,
|
connection,
|
||||||
initialConfig,
|
initialConfig,
|
||||||
options,
|
options,
|
||||||
@@ -49,9 +66,10 @@ export function serve({
|
|||||||
buildConfig,
|
buildConfig,
|
||||||
adminOptions,
|
adminOptions,
|
||||||
distPath,
|
distPath,
|
||||||
});
|
},
|
||||||
return app.fetch(request);
|
args,
|
||||||
},
|
opts,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Server is running on http://localhost:${port}`);
|
console.log(`Server is running on http://localhost:${port}`);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { expect, test, mock } from "bun:test";
|
||||||
|
|
||||||
|
export const bunTestRunner = {
|
||||||
|
expect,
|
||||||
|
test,
|
||||||
|
mock,
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||||
|
import { makeApp } from "./modes/fresh";
|
||||||
|
import { makeConfig } from "./config";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import type { CloudflareBkndConfig } from "./cloudflare-workers.adapter";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("cf adapter", () => {
|
||||||
|
const DB_URL = ":memory:";
|
||||||
|
const $ctx = (env?: any, request?: Request, ctx?: ExecutionContext) => ({
|
||||||
|
request: request ?? (null as any),
|
||||||
|
env: env ?? { DB_URL },
|
||||||
|
ctx: ctx ?? (null as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes config", async () => {
|
||||||
|
expect(
|
||||||
|
makeConfig(
|
||||||
|
{
|
||||||
|
connection: { url: DB_URL },
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
).toEqual({ connection: { url: DB_URL } });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
makeConfig(
|
||||||
|
{
|
||||||
|
app: (env) => ({
|
||||||
|
connection: { url: env.DB_URL },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DB_URL,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual({ connection: { url: DB_URL } });
|
||||||
|
});
|
||||||
|
|
||||||
|
adapterTestSuite<CloudflareBkndConfig, object>(bunTestRunner, {
|
||||||
|
makeApp,
|
||||||
|
makeHandler: (c, a, o) => {
|
||||||
|
return async (request: any) => {
|
||||||
|
const app = await makeApp(
|
||||||
|
// needs a fallback, otherwise tries to launch D1
|
||||||
|
c ?? {
|
||||||
|
connection: { url: DB_URL },
|
||||||
|
},
|
||||||
|
a,
|
||||||
|
o,
|
||||||
|
);
|
||||||
|
return app.fetch(request);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
/// <reference types="@cloudflare/workers-types" />
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import { type FrameworkBkndConfig, makeConfig } from "bknd/adapter";
|
import type { RuntimeBkndConfig } from "bknd/adapter";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { serveStatic } from "hono/cloudflare-workers";
|
import { serveStatic } from "hono/cloudflare-workers";
|
||||||
import { D1Connection } from "./D1Connection";
|
import { getFresh } from "./modes/fresh";
|
||||||
import { registerMedia } from "./StorageR2Adapter";
|
|
||||||
import { getBinding } from "./bindings";
|
|
||||||
import { getCached } from "./modes/cached";
|
import { getCached } from "./modes/cached";
|
||||||
import { getDurable } from "./modes/durable";
|
import { getDurable } from "./modes/durable";
|
||||||
import { getFresh, getWarm } from "./modes/fresh";
|
import type { App } from "bknd";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>> & {
|
export type CloudflareEnv = object;
|
||||||
|
export type CloudflareBkndConfig<Env = CloudflareEnv> = RuntimeBkndConfig<Env> & {
|
||||||
mode?: "warm" | "fresh" | "cache" | "durable";
|
mode?: "warm" | "fresh" | "cache" | "durable";
|
||||||
bindings?: (args: Context<Env>) => {
|
bindings?: (args: Env) => {
|
||||||
kv?: KVNamespace;
|
kv?: KVNamespace;
|
||||||
dobj?: DurableObjectNamespace;
|
dobj?: DurableObjectNamespace;
|
||||||
db?: D1Database;
|
db?: D1Database;
|
||||||
@@ -22,60 +22,28 @@ export type CloudflareBkndConfig<Env = any> = FrameworkBkndConfig<Context<Env>>
|
|||||||
keepAliveSeconds?: number;
|
keepAliveSeconds?: number;
|
||||||
forceHttps?: boolean;
|
forceHttps?: boolean;
|
||||||
manifest?: string;
|
manifest?: string;
|
||||||
setAdminHtml?: boolean;
|
|
||||||
html?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Context<Env = any> = {
|
export type Context<Env = CloudflareEnv> = {
|
||||||
request: Request;
|
request: Request;
|
||||||
env: Env;
|
env: Env;
|
||||||
ctx: ExecutionContext;
|
ctx: ExecutionContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
let media_registered: boolean = false;
|
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
export function makeCfConfig(config: CloudflareBkndConfig, context: Context) {
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
if (!media_registered) {
|
) {
|
||||||
registerMedia(context.env as any);
|
|
||||||
media_registered = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appConfig = makeConfig(config, context);
|
|
||||||
const bindings = config.bindings?.(context);
|
|
||||||
if (!appConfig.connection) {
|
|
||||||
let db: D1Database | undefined;
|
|
||||||
if (bindings?.db) {
|
|
||||||
console.log("Using database from bindings");
|
|
||||||
db = bindings.db;
|
|
||||||
} else if (Object.keys(context.env ?? {}).length > 0) {
|
|
||||||
const binding = getBinding(context.env, "D1Database");
|
|
||||||
if (binding) {
|
|
||||||
console.log(`Using database from env "${binding.key}"`);
|
|
||||||
db = binding.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (db) {
|
|
||||||
appConfig.connection = new D1Connection({ binding: db });
|
|
||||||
} else {
|
|
||||||
throw new Error("No database connection given");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return appConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||||
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'");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.manifest && config.static !== "assets") {
|
if (config.manifest && config.static === "kv") {
|
||||||
const pathname = url.pathname.slice(1);
|
const pathname = url.pathname.slice(1);
|
||||||
const assetManifest = JSON.parse(config.manifest);
|
const assetManifest = JSON.parse(config.manifest);
|
||||||
if (pathname && pathname in assetManifest) {
|
if (pathname && pathname in assetManifest) {
|
||||||
@@ -99,21 +67,27 @@ export function serve<Env = any>(config: CloudflareBkndConfig<Env> = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = { request, env, ctx } as Context;
|
const context = { request, env, ctx } as Context<Env>;
|
||||||
const mode = config.mode ?? "warm";
|
const mode = config.mode ?? "warm";
|
||||||
|
|
||||||
|
let app: App;
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case "fresh":
|
case "fresh":
|
||||||
return await getFresh(config, context);
|
app = await getFresh(config, context, { force: true });
|
||||||
|
break;
|
||||||
case "warm":
|
case "warm":
|
||||||
return await getWarm(config, context);
|
app = await getFresh(config, context);
|
||||||
|
break;
|
||||||
case "cache":
|
case "cache":
|
||||||
return await getCached(config, context);
|
app = await getCached(config, context);
|
||||||
|
break;
|
||||||
case "durable":
|
case "durable":
|
||||||
return await getDurable(config, context);
|
return await getDurable(config, context);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown mode ${mode}`);
|
throw new Error(`Unknown mode ${mode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return app.fetch(request, env, ctx);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { registerMedia } from "./storage/StorageR2Adapter";
|
||||||
|
import { getBinding } from "./bindings";
|
||||||
|
import { D1Connection } from "./D1Connection";
|
||||||
|
import type { CloudflareBkndConfig, CloudflareEnv } from ".";
|
||||||
|
import { App } from "bknd";
|
||||||
|
import { makeConfig as makeAdapterConfig } from "bknd/adapter";
|
||||||
|
import type { ExecutionContext } from "hono";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
|
export const constants = {
|
||||||
|
exec_async_event_id: "cf_register_waituntil",
|
||||||
|
cache_endpoint: "/__bknd/cache",
|
||||||
|
do_endpoint: "/__bknd/do",
|
||||||
|
};
|
||||||
|
|
||||||
|
let media_registered: boolean = false;
|
||||||
|
export function makeConfig<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
|
config: CloudflareBkndConfig<Env>,
|
||||||
|
args: Env = {} as Env,
|
||||||
|
) {
|
||||||
|
if (!media_registered) {
|
||||||
|
registerMedia(args as any);
|
||||||
|
media_registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appConfig = makeAdapterConfig(config, args);
|
||||||
|
const bindings = config.bindings?.(args);
|
||||||
|
if (!appConfig.connection) {
|
||||||
|
let db: D1Database | undefined;
|
||||||
|
if (bindings?.db) {
|
||||||
|
$console.log("Using database from bindings");
|
||||||
|
db = bindings.db;
|
||||||
|
} else if (Object.keys(args).length > 0) {
|
||||||
|
const binding = getBinding(args, "D1Database");
|
||||||
|
if (binding) {
|
||||||
|
$console.log(`Using database from env "${binding.key}"`);
|
||||||
|
db = binding.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (db) {
|
||||||
|
appConfig.connection = new D1Connection({ binding: db });
|
||||||
|
} else {
|
||||||
|
throw new Error("No database connection given");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return appConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAsyncsExecutionContext(
|
||||||
|
app: App,
|
||||||
|
ctx: { waitUntil: ExecutionContext["waitUntil"] },
|
||||||
|
) {
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppBeforeResponse,
|
||||||
|
async (event) => {
|
||||||
|
ctx.waitUntil(event.params.app.emgr.executeAsyncs());
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mode: "sync",
|
||||||
|
id: constants.exec_async_event_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
import { D1Connection, type D1ConnectionConfig } from "./D1Connection";
|
||||||
|
|
||||||
export * from "./cloudflare-workers.adapter";
|
export * from "./cloudflare-workers.adapter";
|
||||||
export { makeApp, getFresh, getWarm } from "./modes/fresh";
|
export { makeApp, getFresh } 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 { D1Connection, type D1ConnectionConfig };
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { App } from "bknd";
|
import { App } from "bknd";
|
||||||
import { createRuntimeApp } from "bknd/adapter";
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||||
|
import { makeConfig, registerAsyncsExecutionContext, constants } from "../config";
|
||||||
|
|
||||||
export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...args }: Context) {
|
export async function getCached<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
|
config: CloudflareBkndConfig<Env>,
|
||||||
|
{ env, ctx, ...args }: Context<Env>,
|
||||||
|
) {
|
||||||
const { kv } = config.bindings?.(env)!;
|
const { kv } = config.bindings?.(env)!;
|
||||||
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
if (!kv) throw new Error("kv namespace is not defined in cloudflare.bindings");
|
||||||
const key = config.key ?? "app";
|
const key = config.key ?? "app";
|
||||||
@@ -16,10 +20,11 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
|
|||||||
|
|
||||||
const app = await createRuntimeApp(
|
const app = await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...makeCfConfig(config, { env, ctx, ...args }),
|
...makeConfig(config, env),
|
||||||
initialConfig,
|
initialConfig,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
app.module.server.client.get("/__bknd/cache", async (c) => {
|
registerAsyncsExecutionContext(app, ctx);
|
||||||
|
app.module.server.client.get(constants.cache_endpoint, async (c) => {
|
||||||
await kv.delete(key);
|
await kv.delete(key);
|
||||||
return c.json({ message: "Cache cleared" });
|
return c.json({ message: "Cache cleared" });
|
||||||
});
|
});
|
||||||
@@ -35,7 +40,6 @@ export async function getCached(config: CloudflareBkndConfig, { env, ctx, ...arg
|
|||||||
);
|
);
|
||||||
await config.beforeBuild?.(app);
|
await config.beforeBuild?.(app);
|
||||||
},
|
},
|
||||||
adminOptions: { html: config.html },
|
|
||||||
},
|
},
|
||||||
{ env, ctx, ...args },
|
{ env, ctx, ...args },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { DurableObject } from "cloudflare:workers";
|
import { DurableObject } from "cloudflare:workers";
|
||||||
import type { App, CreateAppConfig } from "bknd";
|
import type { App, CreateAppConfig } from "bknd";
|
||||||
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
import { createRuntimeApp, makeConfig } from "bknd/adapter";
|
||||||
import type { CloudflareBkndConfig, Context } from "../index";
|
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||||
|
import { constants, registerAsyncsExecutionContext } from "../config";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
export async function getDurable<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
|
config: CloudflareBkndConfig<Env>,
|
||||||
|
ctx: Context<Env>,
|
||||||
|
) {
|
||||||
const { dobj } = config.bindings?.(ctx.env)!;
|
const { dobj } = config.bindings?.(ctx.env)!;
|
||||||
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
|
if (!dobj) throw new Error("durable object is not defined in cloudflare.bindings");
|
||||||
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();
|
||||||
@@ -17,13 +22,11 @@ export async function getDurable(config: CloudflareBkndConfig, ctx: Context) {
|
|||||||
const id = dobj.idFromName(key);
|
const id = dobj.idFromName(key);
|
||||||
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
const stub = dobj.get(id) as unknown as DurableBkndApp;
|
||||||
|
|
||||||
const create_config = makeConfig(config, ctx);
|
const create_config = makeConfig(config, ctx.env);
|
||||||
|
|
||||||
const res = await stub.fire(ctx.request, {
|
const res = await stub.fire(ctx.request, {
|
||||||
config: create_config,
|
config: create_config,
|
||||||
html: config.html,
|
|
||||||
keepAliveSeconds: config.keepAliveSeconds,
|
keepAliveSeconds: config.keepAliveSeconds,
|
||||||
setAdminHtml: config.setAdminHtml,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const headers = new Headers(res.headers);
|
const headers = new Headers(res.headers);
|
||||||
@@ -67,7 +70,8 @@ export class DurableBkndApp extends DurableObject {
|
|||||||
this.app = await createRuntimeApp({
|
this.app = await createRuntimeApp({
|
||||||
...config,
|
...config,
|
||||||
onBuilt: async (app) => {
|
onBuilt: async (app) => {
|
||||||
app.modules.server.get("/__do", async (c) => {
|
registerAsyncsExecutionContext(app, this.ctx);
|
||||||
|
app.modules.server.get(constants.do_endpoint, async (c) => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
const context: any = c.req.raw.cf ? c.req.raw.cf : c.env.cf;
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -92,7 +96,6 @@ export class DurableBkndApp extends DurableObject {
|
|||||||
this.keepAlive(options.keepAliveSeconds);
|
this.keepAlive(options.keepAliveSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("id", this.id);
|
|
||||||
const res = await this.app!.fetch(request);
|
const res = await this.app!.fetch(request);
|
||||||
const headers = new Headers(res.headers);
|
const headers = new Headers(res.headers);
|
||||||
headers.set("X-BuildTime", buildtime.toString());
|
headers.set("X-BuildTime", buildtime.toString());
|
||||||
@@ -106,19 +109,17 @@ export class DurableBkndApp extends DurableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async onBuilt(app: App) {}
|
async onBuilt(app: App) {}
|
||||||
|
|
||||||
async beforeBuild(app: App) {}
|
async beforeBuild(app: App) {}
|
||||||
|
|
||||||
protected keepAlive(seconds: number) {
|
protected keepAlive(seconds: number) {
|
||||||
console.log("keep alive for", seconds);
|
|
||||||
if (this.interval) {
|
if (this.interval) {
|
||||||
console.log("clearing, there is a new");
|
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
this.interval = setInterval(() => {
|
this.interval = setInterval(() => {
|
||||||
i += 1;
|
i += 1;
|
||||||
//console.log("keep-alive", i);
|
|
||||||
if (i === seconds) {
|
if (i === seconds) {
|
||||||
console.log("cleared");
|
console.log("cleared");
|
||||||
clearInterval(this.interval);
|
clearInterval(this.interval);
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import type { App } from "bknd";
|
import { createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import { createRuntimeApp } from "bknd/adapter";
|
import type { CloudflareBkndConfig, Context, CloudflareEnv } from "../index";
|
||||||
import { type CloudflareBkndConfig, type Context, makeCfConfig } from "../index";
|
import { makeConfig, registerAsyncsExecutionContext } from "../config";
|
||||||
|
|
||||||
export async function makeApp(config: CloudflareBkndConfig, ctx: Context) {
|
export async function makeApp<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
return await createRuntimeApp(
|
config: CloudflareBkndConfig<Env>,
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
) {
|
||||||
|
return await createRuntimeApp<Env>(makeConfig(config, args), args, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFresh<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
|
config: CloudflareBkndConfig<Env>,
|
||||||
|
ctx: Context<Env>,
|
||||||
|
opts: RuntimeOptions = {},
|
||||||
|
) {
|
||||||
|
return await makeApp(
|
||||||
{
|
{
|
||||||
...makeCfConfig(config, ctx),
|
...config,
|
||||||
adminOptions: config.html ? { html: config.html } : undefined,
|
onBuilt: async (app) => {
|
||||||
|
registerAsyncsExecutionContext(app, ctx.ctx);
|
||||||
|
await config.onBuilt?.(app);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
ctx,
|
ctx.env,
|
||||||
|
opts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFresh(config: CloudflareBkndConfig, ctx: Context) {
|
|
||||||
const app = await makeApp(config, ctx);
|
|
||||||
return app.fetch(ctx.request);
|
|
||||||
}
|
|
||||||
|
|
||||||
let warm_app: App;
|
|
||||||
export async function getWarm(config: CloudflareBkndConfig, ctx: Context) {
|
|
||||||
if (!warm_app) {
|
|
||||||
warm_app = await makeApp(config, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return warm_app.fetch(ctx.request);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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/test";
|
||||||
|
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();
|
||||||
|
});
|
||||||
+9
-8
@@ -1,9 +1,10 @@
|
|||||||
import { registries } from "bknd";
|
import { registries } from "bknd";
|
||||||
import { isDebug } from "bknd/core";
|
import { isDebug } from "bknd/core";
|
||||||
import { StringEnum, Type } from "bknd/utils";
|
import { StringEnum } from "bknd/utils";
|
||||||
import type { FileBody, StorageAdapter } from "media/storage/Storage";
|
import { guessMimeType as guess, StorageAdapter, type FileBody } from "bknd/media";
|
||||||
import { guess } from "media/storage/mime-types-tiny";
|
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(
|
||||||
@@ -47,8 +48,10 @@ export function registerMedia(env: Record<string, any>) {
|
|||||||
* Adapter for R2 storage
|
* Adapter for R2 storage
|
||||||
* @todo: add tests (bun tests won't work, need node native tests)
|
* @todo: add tests (bun tests won't work, need node native tests)
|
||||||
*/
|
*/
|
||||||
export class StorageR2Adapter implements StorageAdapter {
|
export class StorageR2Adapter extends StorageAdapter {
|
||||||
constructor(private readonly bucket: R2Bucket) {}
|
constructor(private readonly bucket: R2Bucket) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
getName(): string {
|
getName(): string {
|
||||||
return "r2";
|
return "r2";
|
||||||
@@ -121,12 +124,10 @@ export class StorageR2Adapter implements 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: object.range ? 206 : 200,
|
||||||
+80
-43
@@ -12,76 +12,113 @@ export type BkndConfig<Args = any> = CreateAppConfig & {
|
|||||||
|
|
||||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||||
|
|
||||||
|
export type CreateAdapterAppOptions = {
|
||||||
|
force?: boolean;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
export type FrameworkOptions = CreateAdapterAppOptions;
|
||||||
|
export type RuntimeOptions = CreateAdapterAppOptions;
|
||||||
|
|
||||||
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
export type RuntimeBkndConfig<Args = any> = BkndConfig<Args> & {
|
||||||
distPath?: string;
|
distPath?: string;
|
||||||
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
serveStatic?: MiddlewareHandler | [string, MiddlewareHandler];
|
||||||
adminOptions?: AdminControllerOptions | false;
|
adminOptions?: AdminControllerOptions | false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function makeConfig<Args = any>(config: BkndConfig<Args>, args?: Args): CreateAppConfig {
|
export type DefaultArgs = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function makeConfig<Args = DefaultArgs>(
|
||||||
|
config: BkndConfig<Args>,
|
||||||
|
args?: Args,
|
||||||
|
): CreateAppConfig {
|
||||||
let additionalConfig: CreateAppConfig = {};
|
let additionalConfig: CreateAppConfig = {};
|
||||||
if ("app" in config && config.app) {
|
const { app, ...rest } = config;
|
||||||
if (typeof config.app === "function") {
|
if (app) {
|
||||||
|
if (typeof app === "function") {
|
||||||
if (!args) {
|
if (!args) {
|
||||||
throw new Error("args is required when config.app is a function");
|
throw new Error("args is required when config.app is a function");
|
||||||
}
|
}
|
||||||
additionalConfig = config.app(args);
|
additionalConfig = app(args);
|
||||||
} else {
|
} else {
|
||||||
additionalConfig = config.app;
|
additionalConfig = app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...config, ...additionalConfig };
|
return { ...rest, ...additionalConfig };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFrameworkApp<Args = any>(
|
// a map that contains all apps by id
|
||||||
config: FrameworkBkndConfig,
|
const apps = new Map<string, App>();
|
||||||
|
export async function createAdapterApp<Config extends BkndConfig = BkndConfig, Args = DefaultArgs>(
|
||||||
|
config: Config = {} as Config,
|
||||||
args?: Args,
|
args?: Args,
|
||||||
|
opts?: CreateAdapterAppOptions,
|
||||||
): Promise<App> {
|
): Promise<App> {
|
||||||
const app = App.create(makeConfig(config, args));
|
const id = opts?.id ?? "app";
|
||||||
|
let app = apps.get(id);
|
||||||
|
if (!app || opts?.force) {
|
||||||
|
app = App.create(makeConfig(config, args));
|
||||||
|
apps.set(id, app);
|
||||||
|
}
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
if (config.onBuilt) {
|
export async function createFrameworkApp<Args = DefaultArgs>(
|
||||||
|
config: FrameworkBkndConfig = {},
|
||||||
|
args?: Args,
|
||||||
|
opts?: FrameworkOptions,
|
||||||
|
): Promise<App> {
|
||||||
|
const app = await createAdapterApp(config, args, opts);
|
||||||
|
|
||||||
|
if (!app.isBuilt()) {
|
||||||
|
if (config.onBuilt) {
|
||||||
|
app.emgr.onEvent(
|
||||||
|
App.Events.AppBuiltEvent,
|
||||||
|
async () => {
|
||||||
|
await config.onBuilt?.(app);
|
||||||
|
},
|
||||||
|
"sync",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.beforeBuild?.(app);
|
||||||
|
await app.build(config.buildConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRuntimeApp<Args = DefaultArgs>(
|
||||||
|
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig<Args> = {},
|
||||||
|
args?: Args,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
): Promise<App> {
|
||||||
|
const app = await createAdapterApp(config, args, opts);
|
||||||
|
|
||||||
|
if (!app.isBuilt()) {
|
||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
App.Events.AppBuiltEvent,
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
|
if (serveStatic) {
|
||||||
|
const [path, handler] = Array.isArray(serveStatic)
|
||||||
|
? serveStatic
|
||||||
|
: [$config.server.assets_path + "*", serveStatic];
|
||||||
|
app.modules.server.get(path, handler);
|
||||||
|
}
|
||||||
|
|
||||||
await config.onBuilt?.(app);
|
await config.onBuilt?.(app);
|
||||||
|
if (adminOptions !== false) {
|
||||||
|
app.registerAdminController(adminOptions);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sync",
|
"sync",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await config.beforeBuild?.(app);
|
||||||
|
await app.build(config.buildConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
await config.beforeBuild?.(app);
|
|
||||||
await app.build(config.buildConfig);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createRuntimeApp<Env = any>(
|
|
||||||
{ serveStatic, adminOptions, ...config }: RuntimeBkndConfig,
|
|
||||||
env?: Env,
|
|
||||||
): Promise<App> {
|
|
||||||
const app = App.create(makeConfig(config, env));
|
|
||||||
|
|
||||||
app.emgr.onEvent(
|
|
||||||
App.Events.AppBuiltEvent,
|
|
||||||
async () => {
|
|
||||||
if (serveStatic) {
|
|
||||||
const [path, handler] = Array.isArray(serveStatic)
|
|
||||||
? serveStatic
|
|
||||||
: [$config.server.assets_path + "*", serveStatic];
|
|
||||||
app.modules.server.get(path, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
await config.onBuilt?.(app);
|
|
||||||
if (adminOptions !== false) {
|
|
||||||
app.registerAdminController(adminOptions);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"sync",
|
|
||||||
);
|
|
||||||
|
|
||||||
await config.beforeBuild?.(app);
|
|
||||||
await app.build(config.buildConfig);
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as nextjs from "./nextjs.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import type { NextjsBkndConfig } from "./nextjs.adapter";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("nextjs adapter", () => {
|
||||||
|
adapterTestSuite<NextjsBkndConfig>(bunTestRunner, {
|
||||||
|
makeApp: nextjs.getApp,
|
||||||
|
makeHandler: nextjs.serve,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,18 @@
|
|||||||
import type { App } from "bknd";
|
import { createFrameworkApp, type FrameworkBkndConfig, type FrameworkOptions } from "bknd/adapter";
|
||||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
import { isNode } from "bknd/utils";
|
||||||
import { isNode } from "core/utils";
|
import type { NextApiRequest } from "next";
|
||||||
|
|
||||||
export type NextjsBkndConfig = FrameworkBkndConfig & {
|
type NextjsEnv = NextApiRequest["env"];
|
||||||
|
export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
||||||
cleanRequest?: { searchParams?: string[] };
|
cleanRequest?: { searchParams?: string[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
type NextjsContext = {
|
export async function getApp<Env = NextjsEnv>(
|
||||||
env: Record<string, string | undefined>;
|
config: NextjsBkndConfig<Env>,
|
||||||
};
|
args: Env = {} as Env,
|
||||||
|
opts?: FrameworkOptions,
|
||||||
let app: App;
|
|
||||||
let building: boolean = false;
|
|
||||||
|
|
||||||
export async function getApp<Args extends NextjsContext = NextjsContext>(
|
|
||||||
config: NextjsBkndConfig,
|
|
||||||
args?: Args,
|
|
||||||
) {
|
) {
|
||||||
if (building) {
|
return await createFrameworkApp(config, args ?? (process.env as Env), opts);
|
||||||
while (building) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
if (app) return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
if (!app) {
|
|
||||||
app = await createFrameworkApp(config, args);
|
|
||||||
await app.build();
|
|
||||||
}
|
|
||||||
building = false;
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequest"]) {
|
||||||
@@ -56,11 +38,13 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve({ cleanRequest, ...config }: NextjsBkndConfig = {}) {
|
export function serve<Env = NextjsEnv>(
|
||||||
|
{ cleanRequest, ...config }: NextjsBkndConfig<Env> = {},
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: FrameworkOptions,
|
||||||
|
) {
|
||||||
return async (req: Request) => {
|
return async (req: Request) => {
|
||||||
if (!app) {
|
const app = await getApp(config, args, opts);
|
||||||
app = await getApp(config, { env: process.env ?? {} });
|
|
||||||
}
|
|
||||||
const request = getCleanRequest(req, cleanRequest);
|
const request = getCleanRequest(req, cleanRequest);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { registries } from "bknd";
|
import { registries } from "bknd";
|
||||||
import {
|
import { type LocalAdapterConfig, StorageLocalAdapter } from "./storage/StorageLocalAdapter";
|
||||||
type LocalAdapterConfig,
|
|
||||||
StorageLocalAdapter,
|
|
||||||
} from "../../media/storage/adapters/StorageLocalAdapter";
|
|
||||||
|
|
||||||
export * from "./node.adapter";
|
export * from "./node.adapter";
|
||||||
export { StorageLocalAdapter, type LocalAdapterConfig };
|
export { StorageLocalAdapter, type LocalAdapterConfig };
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
export function registerLocalMediaAdapter() {
|
export function registerLocalMediaAdapter() {
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
if (!registered) {
|
||||||
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
|
registered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (config: Partial<LocalAdapterConfig> = {}) => {
|
||||||
|
const adapter = new StorageLocalAdapter(config);
|
||||||
|
return adapter.toJSON(true);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, before, after } from "node:test";
|
||||||
|
import * as node from "./node.adapter";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { nodeTestRunner } from "adapter/node/test";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
|
||||||
|
before(() => disableConsoleLog());
|
||||||
|
after(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("node adapter", () => {
|
||||||
|
adapterTestSuite(nodeTestRunner, {
|
||||||
|
makeApp: node.createApp,
|
||||||
|
makeHandler: node.createHandler,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as node from "./node.adapter";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("node adapter (bun)", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: node.createApp,
|
||||||
|
makeHandler: node.createHandler,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,12 @@ 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/index";
|
||||||
import type { App } from "bknd";
|
import { type RuntimeBkndConfig, createRuntimeApp, type RuntimeOptions } from "bknd/adapter";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
|
||||||
import { config as $config } from "bknd/core";
|
import { config as $config } from "bknd/core";
|
||||||
|
import { $console } from "core";
|
||||||
|
|
||||||
export type NodeBkndConfig = RuntimeBkndConfig & {
|
type NodeEnv = NodeJS.ProcessEnv;
|
||||||
|
export type NodeBkndConfig<Env = NodeEnv> = RuntimeBkndConfig<Env> & {
|
||||||
port?: number;
|
port?: number;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
listener?: Parameters<typeof honoServe>[1];
|
listener?: Parameters<typeof honoServe>[1];
|
||||||
@@ -14,14 +15,11 @@ export type NodeBkndConfig = RuntimeBkndConfig & {
|
|||||||
relativeDistPath?: string;
|
relativeDistPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serve({
|
export async function createApp<Env = NodeEnv>(
|
||||||
distPath,
|
{ distPath, relativeDistPath, ...config }: NodeBkndConfig<Env> = {},
|
||||||
relativeDistPath,
|
args: Env = {} as Env,
|
||||||
port = $config.server.default_port,
|
opts?: RuntimeOptions,
|
||||||
hostname,
|
) {
|
||||||
listener,
|
|
||||||
...config
|
|
||||||
}: NodeBkndConfig = {}) {
|
|
||||||
const root = path.relative(
|
const root = path.relative(
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
|
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
|
||||||
@@ -30,26 +28,42 @@ export function serve({
|
|||||||
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
registerLocalMediaAdapter();
|
||||||
|
return await createRuntimeApp(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
serveStatic: serveStatic({ root }),
|
||||||
|
},
|
||||||
|
// @ts-ignore
|
||||||
|
args ?? { env: process.env },
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHandler<Env = NodeEnv>(
|
||||||
|
config: NodeBkndConfig<Env> = {},
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
) {
|
||||||
|
return async (req: Request) => {
|
||||||
|
const app = await createApp(config, args ?? (process.env as Env), opts);
|
||||||
|
return app.fetch(req);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serve<Env = NodeEnv>(
|
||||||
|
{ port = $config.server.default_port, hostname, listener, ...config }: NodeBkndConfig<Env> = {},
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: RuntimeOptions,
|
||||||
|
) {
|
||||||
honoServe(
|
honoServe(
|
||||||
{
|
{
|
||||||
port,
|
port,
|
||||||
hostname,
|
hostname,
|
||||||
fetch: async (req: Request) => {
|
fetch: createHandler(config, args, opts),
|
||||||
if (!app) {
|
|
||||||
registerLocalMediaAdapter();
|
|
||||||
app = await createRuntimeApp({
|
|
||||||
...config,
|
|
||||||
serveStatic: serveStatic({ root }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.fetch(req);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
(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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe } from "node:test";
|
||||||
|
import { nodeTestRunner } from "adapter/node/test";
|
||||||
|
import { StorageLocalAdapter } from "adapter/node";
|
||||||
|
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
describe("StorageLocalAdapter (node)", async () => {
|
||||||
|
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" });
|
||||||
|
|
||||||
|
const adapter = new StorageLocalAdapter({
|
||||||
|
path: path.join(basePath, "tmp"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await adapterTestSuite(nodeTestRunner, adapter, file);
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, test, expect } from "bun:test";
|
||||||
|
import { StorageLocalAdapter } from "./StorageLocalAdapter";
|
||||||
|
// @ts-ignore
|
||||||
|
import { assetsPath, assetsTmpPath } from "../../../../__test__/helper";
|
||||||
|
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
describe("StorageLocalAdapter (bun)", async () => {
|
||||||
|
const adapter = new StorageLocalAdapter({
|
||||||
|
path: assetsTmpPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = Bun.file(`${assetsPath}/image.png`);
|
||||||
|
await adapterTestSuite(bunTestRunner, adapter, file);
|
||||||
|
});
|
||||||
+9
-12
@@ -1,26 +1,23 @@
|
|||||||
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 "core/utils";
|
import { type Static, isFile, parse } from "bknd/utils";
|
||||||
import type {
|
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd/media";
|
||||||
FileBody,
|
import { StorageAdapter, guessMimeType as guess } from "bknd/media";
|
||||||
FileListObject,
|
import * as tb from "@sinclair/typebox";
|
||||||
FileMeta,
|
const { Type } = tb;
|
||||||
FileUploadPayload,
|
|
||||||
StorageAdapter,
|
|
||||||
} from "../../Storage";
|
|
||||||
import { guess } from "../../mime-types-tiny";
|
|
||||||
|
|
||||||
export const localAdapterConfig = Type.Object(
|
export const localAdapterConfig = Type.Object(
|
||||||
{
|
{
|
||||||
path: Type.String({ default: "./" }),
|
path: Type.String({ default: "./" }),
|
||||||
},
|
},
|
||||||
{ title: "Local", description: "Local file system storage" },
|
{ title: "Local", description: "Local file system storage", additionalProperties: false },
|
||||||
);
|
);
|
||||||
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
export type LocalAdapterConfig = Static<typeof localAdapterConfig>;
|
||||||
|
|
||||||
export class StorageLocalAdapter implements StorageAdapter {
|
export class StorageLocalAdapter extends StorageAdapter {
|
||||||
private config: LocalAdapterConfig;
|
private config: LocalAdapterConfig;
|
||||||
|
|
||||||
constructor(config: any) {
|
constructor(config: Partial<LocalAdapterConfig> = {}) {
|
||||||
|
super();
|
||||||
this.config = parse(localAdapterConfig, config);
|
this.config = parse(localAdapterConfig, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import nodeAssert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { Matcher, Test, TestFn, TestRunner } from "core/test";
|
||||||
|
|
||||||
|
// Track mock function calls
|
||||||
|
const mockCalls = new WeakMap<Function, number>();
|
||||||
|
function createMockFunction<T extends (...args: any[]) => any>(fn: T): T {
|
||||||
|
const mockFn = (...args: Parameters<T>) => {
|
||||||
|
const currentCalls = mockCalls.get(mockFn) || 0;
|
||||||
|
mockCalls.set(mockFn, currentCalls + 1);
|
||||||
|
return fn(...args);
|
||||||
|
};
|
||||||
|
return mockFn as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTestMatcher = <T = unknown>(actual: T, parentFailMsg?: string) =>
|
||||||
|
({
|
||||||
|
toEqual: (expected: T, failMsg = parentFailMsg) => {
|
||||||
|
nodeAssert.deepEqual(actual, expected, failMsg);
|
||||||
|
},
|
||||||
|
toBe: (expected: T, failMsg = parentFailMsg) => {
|
||||||
|
nodeAssert.strictEqual(actual, expected, failMsg);
|
||||||
|
},
|
||||||
|
toBeString: (failMsg = parentFailMsg) => {
|
||||||
|
nodeAssert.strictEqual(typeof actual, "string", failMsg);
|
||||||
|
},
|
||||||
|
toBeUndefined: (failMsg = parentFailMsg) => {
|
||||||
|
nodeAssert.strictEqual(actual, undefined, failMsg);
|
||||||
|
},
|
||||||
|
toBeDefined: (failMsg = parentFailMsg) => {
|
||||||
|
nodeAssert.notStrictEqual(actual, undefined, failMsg);
|
||||||
|
},
|
||||||
|
toBeOneOf: (expected: T | Array<T> | Iterable<T>, failMsg = parentFailMsg) => {
|
||||||
|
const e = Array.isArray(expected) ? expected : [expected];
|
||||||
|
nodeAssert.ok(e.includes(actual), failMsg);
|
||||||
|
},
|
||||||
|
toHaveBeenCalled: (failMsg = parentFailMsg) => {
|
||||||
|
const calls = mockCalls.get(actual as Function) || 0;
|
||||||
|
nodeAssert.ok(calls > 0, failMsg || "Expected function to have been called at least once");
|
||||||
|
},
|
||||||
|
toHaveBeenCalledTimes: (expected: number, failMsg = parentFailMsg) => {
|
||||||
|
const calls = mockCalls.get(actual as Function) || 0;
|
||||||
|
nodeAssert.strictEqual(
|
||||||
|
calls,
|
||||||
|
expected,
|
||||||
|
failMsg || `Expected function to have been called ${expected} times`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}) satisfies Matcher<T>;
|
||||||
|
|
||||||
|
const nodeTestResolverProxy = <T = unknown>(
|
||||||
|
actual: Promise<T>,
|
||||||
|
handler: { resolve?: any; reject?: any },
|
||||||
|
) => {
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_, prop) => {
|
||||||
|
if (prop === "then") {
|
||||||
|
return actual.then(handler.resolve, handler.reject);
|
||||||
|
}
|
||||||
|
return actual;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
) as Matcher<Awaited<T>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function nodeTest(label: string, fn: TestFn, options?: any) {
|
||||||
|
return test(label, fn as any);
|
||||||
|
}
|
||||||
|
nodeTest.if = (condition: boolean): Test => {
|
||||||
|
if (condition) {
|
||||||
|
return nodeTest;
|
||||||
|
}
|
||||||
|
return (() => {}) as any;
|
||||||
|
};
|
||||||
|
nodeTest.skip = (label: string, fn: TestFn) => {
|
||||||
|
return test.skip(label, fn as any);
|
||||||
|
};
|
||||||
|
nodeTest.skipIf = (condition: boolean): Test => {
|
||||||
|
if (condition) {
|
||||||
|
return (() => {}) as any;
|
||||||
|
}
|
||||||
|
return nodeTest;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodeTestRunner: TestRunner = {
|
||||||
|
test: nodeTest,
|
||||||
|
mock: createMockFunction,
|
||||||
|
expect: <T = unknown>(actual?: T, failMsg?: string) => ({
|
||||||
|
...nodeTestMatcher(actual, failMsg),
|
||||||
|
resolves: nodeTestResolverProxy(actual as Promise<T>, {
|
||||||
|
resolve: (r) => nodeTestMatcher(r, failMsg),
|
||||||
|
}),
|
||||||
|
rejects: nodeTestResolverProxy(actual as Promise<T>, {
|
||||||
|
reject: (r) => nodeTestMatcher(r, failMsg),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { afterAll, beforeAll, describe } from "bun:test";
|
||||||
|
import * as rr from "./react-router.adapter";
|
||||||
|
import { disableConsoleLog, enableConsoleLog } from "core/utils";
|
||||||
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
beforeAll(disableConsoleLog);
|
||||||
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
|
describe("react-router adapter", () => {
|
||||||
|
adapterTestSuite(bunTestRunner, {
|
||||||
|
makeApp: rr.getApp,
|
||||||
|
makeHandler: (c, a, o) => (request: Request) => rr.serve(c, a?.env, o)({ request }),
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,39 +1,26 @@
|
|||||||
import type { App } from "bknd";
|
|
||||||
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter";
|
||||||
|
import type { FrameworkOptions } from "adapter";
|
||||||
|
|
||||||
type ReactRouterContext = {
|
type ReactRouterEnv = NodeJS.ProcessEnv;
|
||||||
|
type ReactRouterFunctionArgs = {
|
||||||
request: Request;
|
request: Request;
|
||||||
};
|
};
|
||||||
export type ReactRouterBkndConfig<Args = ReactRouterContext> = FrameworkBkndConfig<Args>;
|
export type ReactRouterBkndConfig<Env = ReactRouterEnv> = FrameworkBkndConfig<Env>;
|
||||||
|
|
||||||
let app: App;
|
export async function getApp<Env = ReactRouterEnv>(
|
||||||
let building: boolean = false;
|
config: ReactRouterBkndConfig<Env>,
|
||||||
|
args: Env = {} as Env,
|
||||||
export async function getApp<Args extends ReactRouterContext = ReactRouterContext>(
|
opts?: FrameworkOptions,
|
||||||
config: ReactRouterBkndConfig<Args>,
|
|
||||||
args?: Args,
|
|
||||||
) {
|
) {
|
||||||
if (building) {
|
return await createFrameworkApp(config, args ?? process.env, opts);
|
||||||
while (building) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
if (app) return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
building = true;
|
|
||||||
if (!app) {
|
|
||||||
app = await createFrameworkApp(config, args);
|
|
||||||
await app.build();
|
|
||||||
}
|
|
||||||
building = false;
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serve<Args extends ReactRouterContext = ReactRouterContext>(
|
export function serve<Env = ReactRouterEnv>(
|
||||||
config: ReactRouterBkndConfig<Args> = {},
|
config: ReactRouterBkndConfig<Env> = {},
|
||||||
|
args: Env = {} as Env,
|
||||||
|
opts?: FrameworkOptions,
|
||||||
) {
|
) {
|
||||||
return async (args: Args) => {
|
return async (fnArgs: ReactRouterFunctionArgs) => {
|
||||||
app = await getApp(config, args);
|
return (await getApp(config, args, opts)).fetch(fnArgs.request);
|
||||||
return app.fetch(args.request);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { type DevServerOptions, default as honoViteDevServer } from "@hono/vite-dev-server";
|
import {
|
||||||
|
type DevServerOptions,
|
||||||
|
default as honoViteDevServer,
|
||||||
|
} from "@hono/vite-dev-server";
|
||||||
import type { App } from "bknd";
|
import type { App } from "bknd";
|
||||||
import { type RuntimeBkndConfig, createRuntimeApp } from "bknd/adapter";
|
import {
|
||||||
|
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";
|
||||||
|
|
||||||
export type ViteBkndConfig<Env = any> = RuntimeBkndConfig<Env> & {
|
export type ViteEnv = NodeJS.ProcessEnv;
|
||||||
mode?: "cached" | "fresh";
|
export type ViteBkndConfig<Env = ViteEnv> = RuntimeBkndConfig<Env> & {};
|
||||||
setAdminHtml?: boolean;
|
|
||||||
forceDev?: boolean | { mainPath: string };
|
|
||||||
html?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function addViteScript(html: string, addBkndContext: boolean = true) {
|
export function addViteScript(
|
||||||
|
html: string,
|
||||||
|
addBkndContext: boolean = true,
|
||||||
|
) {
|
||||||
return html.replace(
|
return html.replace(
|
||||||
"</head>",
|
"</head>",
|
||||||
`<script type="module">
|
`<script type="module">
|
||||||
@@ -28,52 +34,40 @@ ${addBkndContext ? "<!-- BKND_CONTEXT -->" : ""}
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApp(config: ViteBkndConfig = {}, env?: any) {
|
async function createApp<ViteEnv>(
|
||||||
|
config: ViteBkndConfig<ViteEnv> = {},
|
||||||
|
env: ViteEnv = {} as ViteEnv,
|
||||||
|
opts: FrameworkOptions = {},
|
||||||
|
): Promise<App> {
|
||||||
registerLocalMediaAdapter();
|
registerLocalMediaAdapter();
|
||||||
return await createRuntimeApp(
|
return await createRuntimeApp(
|
||||||
{
|
{
|
||||||
...config,
|
...config,
|
||||||
adminOptions:
|
adminOptions: config.adminOptions ?? {
|
||||||
config.setAdminHtml === false
|
forceDev: {
|
||||||
? undefined
|
mainPath: "/src/main.tsx",
|
||||||
: {
|
},
|
||||||
html: config.html,
|
},
|
||||||
forceDev: config.forceDev ?? {
|
|
||||||
mainPath: "/src/main.tsx",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
serveStatic: ["/assets/*", serveStatic({ root: config.distPath ?? "./" })],
|
||||||
},
|
},
|
||||||
env,
|
env,
|
||||||
|
opts,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serveFresh(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
export function serve<ViteEnv>(
|
||||||
|
config: ViteBkndConfig<ViteEnv> = {},
|
||||||
|
args?: ViteEnv,
|
||||||
|
opts?: FrameworkOptions,
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
||||||
const app = await createApp(config, env);
|
const app = await createApp(config, env, opts);
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let app: App;
|
|
||||||
export function serveCached(config: Omit<ViteBkndConfig, "mode"> = {}) {
|
|
||||||
return {
|
|
||||||
async fetch(request: Request, env: any, ctx: ExecutionContext) {
|
|
||||||
if (!app) {
|
|
||||||
app = await createApp(config, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.fetch(request, env, ctx);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serve({ mode, ...config }: ViteBkndConfig = {}) {
|
|
||||||
return mode === "fresh" ? serveFresh(config) : serveCached(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function devServer(options: DevServerOptions) {
|
export function devServer(options: DevServerOptions) {
|
||||||
return honoViteDevServer({
|
return honoViteDevServer({
|
||||||
...devServerConfig,
|
...devServerConfig,
|
||||||
|
|||||||
+15
-136
@@ -1,29 +1,23 @@
|
|||||||
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 { $console, type DB } from "core";
|
||||||
import { type Static, secureRandomString, transformObject } from "core/utils";
|
import { 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, text } 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";
|
||||||
|
|
||||||
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 +25,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 +74,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 +84,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 +116,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)) {
|
||||||
@@ -288,7 +168,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 +195,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";
|
||||||
|
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(
|
||||||
return c.json(create.schema);
|
"create/schema.json",
|
||||||
});
|
describeRoute({
|
||||||
|
summary: "Get the schema for creating a user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
return c.json(create.schema);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainHono.route(`/${name}/actions`, hono);
|
mainHono.route(`/${name}/actions`, hono);
|
||||||
@@ -102,42 +109,54 @@ 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({
|
||||||
|
summary: "Get the current user",
|
||||||
|
tags: ["auth"],
|
||||||
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
|
const claims = c.get("auth")?.user;
|
||||||
|
if (claims) {
|
||||||
|
const { data: user } = await this.userRepo.findId(claims.id);
|
||||||
|
return c.json({ user });
|
||||||
|
}
|
||||||
|
|
||||||
hono.route(`/${name}`, strategy.getController(this.auth.authenticator));
|
return c.json({ user: null }, 403);
|
||||||
this.registerStrategyActions(strategy, hono);
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
hono.get("/me", auth(), async (c) => {
|
hono.get(
|
||||||
const claims = c.get("auth")?.user;
|
"/logout",
|
||||||
if (claims) {
|
describeRoute({
|
||||||
const { data: user } = await this.userRepo.findId(claims.id);
|
summary: "Logout the current user",
|
||||||
return c.json({ user });
|
tags: ["auth"],
|
||||||
}
|
}),
|
||||||
|
auth(),
|
||||||
|
async (c) => {
|
||||||
|
await this.auth.authenticator.logout(c);
|
||||||
|
if (this.auth.authenticator.isJsonRequest(c)) {
|
||||||
|
return c.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ user: null }, 403);
|
const referer = c.req.header("referer");
|
||||||
});
|
if (referer) {
|
||||||
|
return c.redirect(referer);
|
||||||
|
}
|
||||||
|
|
||||||
hono.get("/logout", auth(), async (c) => {
|
return c.redirect("/");
|
||||||
await this.auth.authenticator.logout(c);
|
},
|
||||||
if (this.auth.authenticator.isJsonRequest(c)) {
|
);
|
||||||
return c.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const referer = c.req.header("referer");
|
|
||||||
if (referer) {
|
|
||||||
return c.redirect(referer);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +174,15 @@ export class AuthController extends Controller {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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.all("*", (c) => c.notFound());
|
return hono.all("*", (c) => c.notFound());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user