mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-04 17:16:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c26d646bd | |||
| 4859b99954 |
@@ -9,21 +9,6 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:17
|
|
||||||
env:
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_DB: bknd
|
|
||||||
ports:
|
|
||||||
- 5430:5432
|
|
||||||
options: >-
|
|
||||||
--health-cmd pg_isready
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -35,11 +20,11 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v1
|
uses: oven-sh/setup-bun@v1
|
||||||
with:
|
with:
|
||||||
bun-version: "1.3.3"
|
bun-version: "1.3.1"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
run: bun install #--linker=hoisted
|
run: bun install
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: ./app
|
working-directory: ./app
|
||||||
|
|||||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"biome.enabled": true,
|
"biome.enabled": true,
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
//"source.organizeImports.biome": "explicit",
|
"source.organizeImports.biome": "explicit",
|
||||||
"source.fixAll.biome": "explicit"
|
"source.fixAll.biome": "explicit"
|
||||||
},
|
},
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ It's designed to avoid vendor lock-in and architectural limitations. Built exclu
|
|||||||
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
* SQLite: LibSQL, Node SQLite, Bun SQLite, Cloudflare D1, Cloudflare Durable Objects SQLite, SQLocal
|
||||||
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
* Postgres: Vanilla Postgres, Supabase, Neon, Xata
|
||||||
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
* **Frameworks**: React, Next.js, React Router, Astro, Vite, Waku
|
||||||
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem, Origin Private File System (OPFS)
|
* **Storage**: AWS S3, S3-compatible (Tigris, R2, Minio, etc.), Cloudflare R2 (binding), Cloudinary, Filesystem
|
||||||
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
|
* **Deployment**: Standalone, Docker, Cloudflare Workers, Vercel, Netlify, Deno Deploy, AWS Lambda, Valtown etc.
|
||||||
|
|
||||||
**For documentation and examples, please visit https://docs.bknd.io.**
|
**For documentation and examples, please visit https://docs.bknd.io.**
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ VITE_SHOW_ROUTES=
|
|||||||
|
|
||||||
# ===== Test Credentials =====
|
# ===== Test Credentials =====
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
PLUNK_API_KEY=
|
|
||||||
R2_TOKEN=
|
R2_TOKEN=
|
||||||
|
|
||||||
R2_ACCESS_KEY=
|
R2_ACCESS_KEY=
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe("MediaApi", () => {
|
|||||||
const res = await mockedBackend.request("/api/media/file/" + name);
|
const res = await mockedBackend.request("/api/media/file/" + name);
|
||||||
await Bun.write(path, res);
|
await Bun.write(path, res);
|
||||||
|
|
||||||
const file = Bun.file(path);
|
const file = await Bun.file(path);
|
||||||
expect(file.size).toBeGreaterThan(0);
|
expect(file.size).toBeGreaterThan(0);
|
||||||
expect(file.type).toBe("image/png");
|
expect(file.type).toBe("image/png");
|
||||||
await file.delete();
|
await file.delete();
|
||||||
@@ -154,12 +154,15 @@ describe("MediaApi", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// upload via readable from bun
|
// upload via readable from bun
|
||||||
await matches(api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
|
await matches(await api.upload(file.stream(), { filename: "readable.png" }), "readable.png");
|
||||||
|
|
||||||
// upload via readable from response
|
// upload via readable from response
|
||||||
{
|
{
|
||||||
const response = (await mockedBackend.request(url)) as Response;
|
const response = (await mockedBackend.request(url)) as Response;
|
||||||
await matches(api.upload(response.body!, { filename: "readable.png" }), "readable.png");
|
await matches(
|
||||||
|
await api.upload(response.body!, { filename: "readable.png" }),
|
||||||
|
"readable.png",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { describe, expect, mock, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, mock, test } from "bun:test";
|
||||||
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
import { createApp as internalCreateApp, type CreateAppConfig } from "bknd";
|
||||||
import { getDummyConnection } from "../../__test__/helper";
|
import { getDummyConnection } from "../../__test__/helper";
|
||||||
import { ModuleManager } from "modules/ModuleManager";
|
import { ModuleManager } from "modules/ModuleManager";
|
||||||
import { em, entity, text } from "data/prototype";
|
import { em, entity, text } from "data/prototype";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
async function createApp(config: CreateAppConfig = {}) {
|
async function createApp(config: CreateAppConfig = {}) {
|
||||||
const app = internalCreateApp({
|
const app = internalCreateApp({
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { AppEvents } from "App";
|
import { AppEvents } from "App";
|
||||||
import { describe, test, expect, beforeAll, mock, afterAll } from "bun:test";
|
import { describe, test, expect, beforeAll, mock } from "bun:test";
|
||||||
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
import { type App, createApp, createMcpToolCaller } from "core/test/utils";
|
||||||
import type { McpServer } from "bknd/utils";
|
import type { McpServer } from "bknd/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - [x] system_config
|
* - [x] system_config
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
|
||||||
import { code, hybrid } from "modes";
|
|
||||||
|
|
||||||
describe("modes", () => {
|
|
||||||
describe("code", () => {
|
|
||||||
test("verify base configuration", async () => {
|
|
||||||
const c = code({}) as any;
|
|
||||||
const config = await c.app?.({} as any);
|
|
||||||
expect(Object.keys(config)).toEqual(["options"]);
|
|
||||||
expect(config.options.mode).toEqual("code");
|
|
||||||
expect(config.options.plugins).toEqual([]);
|
|
||||||
expect(config.options.manager.skipValidation).toEqual(false);
|
|
||||||
expect(config.options.manager.onModulesBuilt).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps overrides", async () => {
|
|
||||||
const c = code({
|
|
||||||
connection: {
|
|
||||||
url: ":memory:",
|
|
||||||
},
|
|
||||||
}) as any;
|
|
||||||
const config = await c.app?.({} as any);
|
|
||||||
expect(config.connection.url).toEqual(":memory:");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hybrid", () => {
|
|
||||||
test("fails if no reader is provided", () => {
|
|
||||||
// @ts-ignore
|
|
||||||
expect(hybrid({} as any).app?.({} as any)).rejects.toThrow(/reader/);
|
|
||||||
});
|
|
||||||
test("verify base configuration", async () => {
|
|
||||||
const c = hybrid({ reader: async () => ({}) }) as any;
|
|
||||||
const config = await c.app?.({} as any);
|
|
||||||
expect(Object.keys(config)).toEqual(["reader", "beforeBuild", "config", "options"]);
|
|
||||||
expect(config.options.mode).toEqual("db");
|
|
||||||
expect(config.options.plugins).toEqual([]);
|
|
||||||
expect(config.options.manager.skipValidation).toEqual(false);
|
|
||||||
expect(config.options.manager.onModulesBuilt).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
import { Guard, type GuardConfig } from "auth/authorize/Guard";
|
||||||
import { Permission } from "auth/authorize/Permission";
|
import { Permission } from "auth/authorize/Permission";
|
||||||
import { Role, type RoleSchema } from "auth/authorize/Role";
|
import { Role, type RoleSchema } from "auth/authorize/Role";
|
||||||
import { objectTransform, s } from "bknd/utils";
|
import { objectTransform, s } from "bknd/utils";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
function createGuard(
|
function createGuard(
|
||||||
permissionNames: string[],
|
permissionNames: string[],
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import type { App, DB } from "bknd";
|
|||||||
import type { CreateUserPayload } from "auth/AppAuth";
|
import type { CreateUserPayload } from "auth/AppAuth";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(() => disableConsoleLog());
|
||||||
afterAll(enableConsoleLog);
|
afterAll(() => enableConsoleLog());
|
||||||
|
|
||||||
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
async function makeApp(config: Partial<CreateAppConfig["config"]> = {}) {
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
||||||
import { createAuthTestApp } from "./shared";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
import { em, entity, text } from "data/prototype";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
const schema = em(
|
|
||||||
{
|
|
||||||
posts: entity("posts", {
|
|
||||||
title: text(),
|
|
||||||
content: text(),
|
|
||||||
}),
|
|
||||||
comments: entity("comments", {
|
|
||||||
content: text(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
({ relation }, { posts, comments }) => {
|
|
||||||
relation(posts).manyToOne(comments);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("DataController (auth)", () => {
|
|
||||||
test("reading schema.json", async () => {
|
|
||||||
const { request } = await createAuthTestApp(
|
|
||||||
{
|
|
||||||
permission: ["system.access.api", "data.entity.read", "system.schema.read"],
|
|
||||||
request: new Request("http://localhost/api/data/schema.json"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
config: { data: schema.toJSON() },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, it, expect } from "bun:test";
|
||||||
|
import { SystemController } from "modules/server/SystemController";
|
||||||
|
import { createApp } from "core/test/utils";
|
||||||
|
import type { CreateAppConfig } from "App";
|
||||||
|
import { getPermissionRoutes } from "auth/middlewares/permission.middleware";
|
||||||
|
|
||||||
|
async function makeApp(config: Partial<CreateAppConfig> = {}) {
|
||||||
|
const app = createApp(config);
|
||||||
|
await app.build();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.skip("SystemController", () => {
|
||||||
|
it("...", async () => {
|
||||||
|
const app = await makeApp();
|
||||||
|
const controller = new SystemController(app);
|
||||||
|
const hono = controller.getController();
|
||||||
|
console.log(getPermissionRoutes(hono));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
||||||
import { createAuthTestApp } from "./shared";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("SystemController (auth)", () => {
|
|
||||||
test("reading info", async () => {
|
|
||||||
const { request } = await createAuthTestApp({
|
|
||||||
permission: ["system.access.api", "system.info"],
|
|
||||||
request: new Request("http://localhost/api/system/info"),
|
|
||||||
});
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reading permissions", async () => {
|
|
||||||
const { request } = await createAuthTestApp({
|
|
||||||
permission: ["system.access.api", "system.schema.read"],
|
|
||||||
request: new Request("http://localhost/api/system/permissions"),
|
|
||||||
});
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("access openapi", async () => {
|
|
||||||
const { request } = await createAuthTestApp({
|
|
||||||
permission: ["system.access.api", "system.openapi"],
|
|
||||||
request: new Request("http://localhost/api/system/openapi.json"),
|
|
||||||
});
|
|
||||||
expect((await request.guest()).status).toBe(403);
|
|
||||||
expect((await request.member()).status).toBe(403);
|
|
||||||
expect((await request.authorized()).status).toBe(200);
|
|
||||||
expect((await request.admin()).status).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import { createApp } from "core/test/utils";
|
|
||||||
import type { CreateAppConfig } from "App";
|
|
||||||
import type { RoleSchema } from "auth/authorize/Role";
|
|
||||||
import { isPlainObject } from "core/utils";
|
|
||||||
|
|
||||||
export type AuthTestConfig = {
|
|
||||||
guest?: RoleSchema;
|
|
||||||
member?: RoleSchema;
|
|
||||||
authorized?: RoleSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createAuthTestApp(
|
|
||||||
testConfig: {
|
|
||||||
permission: AuthTestConfig | string | string[];
|
|
||||||
request: Request;
|
|
||||||
},
|
|
||||||
config: Partial<CreateAppConfig> = {},
|
|
||||||
) {
|
|
||||||
let member: RoleSchema | undefined;
|
|
||||||
let authorized: RoleSchema | undefined;
|
|
||||||
let guest: RoleSchema | undefined;
|
|
||||||
if (isPlainObject(testConfig.permission)) {
|
|
||||||
if (testConfig.permission.guest)
|
|
||||||
guest = {
|
|
||||||
...testConfig.permission.guest,
|
|
||||||
is_default: true,
|
|
||||||
};
|
|
||||||
if (testConfig.permission.member) member = testConfig.permission.member;
|
|
||||||
if (testConfig.permission.authorized) authorized = testConfig.permission.authorized;
|
|
||||||
} else {
|
|
||||||
member = {
|
|
||||||
permissions: [],
|
|
||||||
};
|
|
||||||
authorized = {
|
|
||||||
permissions: Array.isArray(testConfig.permission)
|
|
||||||
? testConfig.permission
|
|
||||||
: [testConfig.permission],
|
|
||||||
};
|
|
||||||
guest = {
|
|
||||||
permissions: [],
|
|
||||||
is_default: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("authorized", authorized);
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
...config,
|
|
||||||
config: {
|
|
||||||
...config.config,
|
|
||||||
auth: {
|
|
||||||
...config.config?.auth,
|
|
||||||
enabled: true,
|
|
||||||
guard: {
|
|
||||||
enabled: true,
|
|
||||||
...config.config?.auth?.guard,
|
|
||||||
},
|
|
||||||
jwt: {
|
|
||||||
...config.config?.auth?.jwt,
|
|
||||||
secret: "secret",
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
...config.config?.auth?.roles,
|
|
||||||
guest,
|
|
||||||
member,
|
|
||||||
authorized,
|
|
||||||
admin: {
|
|
||||||
implicit_allow: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const users = {
|
|
||||||
guest: null,
|
|
||||||
member: await app.createUser({
|
|
||||||
email: "member@test.com",
|
|
||||||
password: "12345678",
|
|
||||||
role: "member",
|
|
||||||
}),
|
|
||||||
authorized: await app.createUser({
|
|
||||||
email: "authorized@test.com",
|
|
||||||
password: "12345678",
|
|
||||||
role: "authorized",
|
|
||||||
}),
|
|
||||||
admin: await app.createUser({
|
|
||||||
email: "admin@test.com",
|
|
||||||
password: "12345678",
|
|
||||||
role: "admin",
|
|
||||||
}),
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const tokens = {} as Record<keyof typeof users, string>;
|
|
||||||
for (const [key, user] of Object.entries(users)) {
|
|
||||||
if (user) {
|
|
||||||
tokens[key as keyof typeof users] = await app.module.auth.authenticator.jwt(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeRequest(user: keyof typeof users, input: string, init: RequestInit = {}) {
|
|
||||||
const headers = new Headers(init.headers ?? {});
|
|
||||||
if (user in tokens) {
|
|
||||||
headers.set("Authorization", `Bearer ${tokens[user as keyof typeof tokens]}`);
|
|
||||||
}
|
|
||||||
const res = await app.server.request(input, {
|
|
||||||
...init,
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
let data: any;
|
|
||||||
if (res.headers.get("Content-Type")?.startsWith("application/json")) {
|
|
||||||
data = await res.json();
|
|
||||||
} else if (res.headers.get("Content-Type")?.startsWith("text/")) {
|
|
||||||
data = await res.text();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: res.status,
|
|
||||||
ok: res.ok,
|
|
||||||
headers: Object.fromEntries(res.headers.entries()),
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestFn = new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get(_, prop: keyof typeof users) {
|
|
||||||
return async (input: string, init: RequestInit = {}) => {
|
|
||||||
return makeRequest(prop, input, init);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
) as {
|
|
||||||
[K in keyof typeof users]: (
|
|
||||||
input: string,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => Promise<{
|
|
||||||
status: number;
|
|
||||||
ok: boolean;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
data: any;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const request = new Proxy(
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
get(_, prop: keyof typeof users) {
|
|
||||||
return async () => {
|
|
||||||
return makeRequest(prop, testConfig.request.url, {
|
|
||||||
headers: testConfig.request.headers,
|
|
||||||
method: testConfig.request.method,
|
|
||||||
body: testConfig.request.body,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
) as {
|
|
||||||
[K in keyof typeof users]: () => Promise<{
|
|
||||||
status: number;
|
|
||||||
ok: boolean;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
data: any;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { app, users, request, requestFn };
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, beforeAll, afterAll, test } from "bun:test";
|
|
||||||
import type { PostgresConnection } from "data/connection/postgres/PostgresConnection";
|
|
||||||
import { pg, postgresJs } from "bknd";
|
|
||||||
import { Pool } from "pg";
|
|
||||||
import postgres from "postgres";
|
|
||||||
import { disableConsoleLog, enableConsoleLog, $waitUntil } from "bknd/utils";
|
|
||||||
import { $ } from "bun";
|
|
||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
|
|
||||||
const credentials = {
|
|
||||||
host: "localhost",
|
|
||||||
port: 5430,
|
|
||||||
user: "postgres",
|
|
||||||
password: "postgres",
|
|
||||||
database: "bknd",
|
|
||||||
};
|
|
||||||
|
|
||||||
async function cleanDatabase(connection: InstanceType<typeof PostgresConnection>) {
|
|
||||||
const kysely = connection.kysely;
|
|
||||||
|
|
||||||
// drop all tables+indexes & create new schema
|
|
||||||
await kysely.schema.dropSchema("public").ifExists().cascade().execute();
|
|
||||||
await kysely.schema.dropIndex("public").ifExists().cascade().execute();
|
|
||||||
await kysely.schema.createSchema("public").execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isPostgresRunning() {
|
|
||||||
try {
|
|
||||||
// Try to actually connect to PostgreSQL
|
|
||||||
const conn = pg({ pool: new Pool(credentials) });
|
|
||||||
await conn.ping();
|
|
||||||
await conn.close();
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("postgres", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
if (!(await isPostgresRunning())) {
|
|
||||||
await $`docker run --rm --name bknd-test-postgres -d -e POSTGRES_PASSWORD=${credentials.password} -e POSTGRES_USER=${credentials.user} -e POSTGRES_DB=${credentials.database} -p ${credentials.port}:5432 postgres:17`;
|
|
||||||
await $waitUntil("Postgres is running", isPostgresRunning);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
disableConsoleLog();
|
|
||||||
});
|
|
||||||
afterAll(async () => {
|
|
||||||
if (await isPostgresRunning()) {
|
|
||||||
try {
|
|
||||||
await $`docker stop bknd-test-postgres`;
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
enableConsoleLog();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.serial.each([
|
|
||||||
["pg", () => pg({ pool: new Pool(credentials) })],
|
|
||||||
["postgresjs", () => postgresJs({ postgres: postgres(credentials) })],
|
|
||||||
])("%s", (name, createConnection) => {
|
|
||||||
connectionTestSuite(
|
|
||||||
{
|
|
||||||
...bunTestRunner,
|
|
||||||
test: test.serial,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
makeConnection: () => {
|
|
||||||
const connection = createConnection();
|
|
||||||
return {
|
|
||||||
connection,
|
|
||||||
dispose: async () => {
|
|
||||||
await cleanDatabase(connection);
|
|
||||||
await connection.close();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
rawDialectDetails: [],
|
|
||||||
disableConsoleLog: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -124,81 +124,6 @@ describe("[Repository]", async () => {
|
|||||||
.then((r) => [r.count, r.total]),
|
.then((r) => [r.count, r.total]),
|
||||||
).resolves.toEqual([undefined, undefined]);
|
).resolves.toEqual([undefined, undefined]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("auto join", async () => {
|
|
||||||
const schema = $em(
|
|
||||||
{
|
|
||||||
posts: $entity("posts", {
|
|
||||||
title: $text(),
|
|
||||||
content: $text(),
|
|
||||||
}),
|
|
||||||
comments: $entity("comments", {
|
|
||||||
content: $text(),
|
|
||||||
}),
|
|
||||||
another: $entity("another", {
|
|
||||||
title: $text(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
({ relation }, { posts, comments }) => {
|
|
||||||
relation(comments).manyToOne(posts);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const em = schema.proto.withConnection(getDummyConnection().dummyConnection);
|
|
||||||
await em.schema().sync({ force: true });
|
|
||||||
|
|
||||||
await em.mutator("posts").insertOne({ title: "post1", content: "content1" });
|
|
||||||
await em
|
|
||||||
.mutator("comments")
|
|
||||||
.insertMany([{ content: "comment1", posts_id: 1 }, { content: "comment2" }] as any);
|
|
||||||
|
|
||||||
const res = await em.repo("comments").findMany({
|
|
||||||
where: {
|
|
||||||
"posts.title": "post1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(res.data as any).toEqual([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
content: "comment1",
|
|
||||||
posts_id: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
{
|
|
||||||
// manual join should still work
|
|
||||||
const res = await em.repo("comments").findMany({
|
|
||||||
join: ["posts"],
|
|
||||||
where: {
|
|
||||||
"posts.title": "post1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(res.data as any).toEqual([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
content: "comment1",
|
|
||||||
posts_id: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// inexistent should be detected and thrown
|
|
||||||
expect(
|
|
||||||
em.repo("comments").findMany({
|
|
||||||
where: {
|
|
||||||
"random.title": "post1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/Invalid where field/);
|
|
||||||
|
|
||||||
// existing alias, but not a relation should throw
|
|
||||||
expect(
|
|
||||||
em.repo("comments").findMany({
|
|
||||||
where: {
|
|
||||||
"another.title": "post1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/Invalid where field/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("[data] Repository (Events)", async () => {
|
describe("[data] Repository (Events)", async () => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ describe("SqliteIntrospector", () => {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -89,7 +89,7 @@ describe("SqliteIntrospector", () => {
|
|||||||
dataType: "INTEGER",
|
dataType: "INTEGER",
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: true,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { assetsPath, assetsTmpPath } from "../helper";
|
|||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
disableConsoleLog();
|
//disableConsoleLog();
|
||||||
registries.media.register("local", StorageLocalAdapter);
|
registries.media.register("local", StorageLocalAdapter);
|
||||||
});
|
});
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ beforeAll(disableConsoleLog);
|
|||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("AppAuth", () => {
|
describe("AppAuth", () => {
|
||||||
|
test.skip("...", () => {
|
||||||
|
const auth = new AppAuth({});
|
||||||
|
console.log(auth.toJSON());
|
||||||
|
console.log(auth.config);
|
||||||
|
});
|
||||||
|
|
||||||
moduleTestSuite(AppAuth);
|
moduleTestSuite(AppAuth);
|
||||||
|
|
||||||
let ctx: ModuleBuildContext;
|
let ctx: ModuleBuildContext;
|
||||||
@@ -33,9 +39,11 @@ describe("AppAuth", () => {
|
|||||||
await auth.build();
|
await auth.build();
|
||||||
|
|
||||||
const oldConfig = auth.toJSON(true);
|
const oldConfig = auth.toJSON(true);
|
||||||
|
//console.log(oldConfig);
|
||||||
await auth.schema().patch("enabled", true);
|
await auth.schema().patch("enabled", true);
|
||||||
await auth.build();
|
await auth.build();
|
||||||
const newConfig = auth.toJSON(true);
|
const newConfig = auth.toJSON(true);
|
||||||
|
//console.log(newConfig);
|
||||||
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
expect(newConfig.jwt.secret).not.toBe(oldConfig.jwt.secret);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ describe("AppAuth", () => {
|
|||||||
const app = new AuthController(auth).getController();
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
|
disableConsoleLog();
|
||||||
const res = await app.request("/password/register", {
|
const res = await app.request("/password/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -71,6 +80,7 @@ describe("AppAuth", () => {
|
|||||||
password: "12345678",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
enableConsoleLog();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const { data: users } = await ctx.em.repository("users").findMany();
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
@@ -109,6 +119,7 @@ describe("AppAuth", () => {
|
|||||||
const app = new AuthController(auth).getController();
|
const app = new AuthController(auth).getController();
|
||||||
|
|
||||||
{
|
{
|
||||||
|
disableConsoleLog();
|
||||||
const res = await app.request("/password/register", {
|
const res = await app.request("/password/register", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -119,6 +130,7 @@ describe("AppAuth", () => {
|
|||||||
password: "12345678",
|
password: "12345678",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
enableConsoleLog();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const { data: users } = await ctx.em.repository("users").findMany();
|
const { data: users } = await ctx.em.repository("users").findMany();
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { createApp } from "core/test/utils";
|
import { createApp } from "core/test/utils";
|
||||||
import { em, entity, text } from "data/prototype";
|
import { em, entity, text } from "data/prototype";
|
||||||
import { registries } from "modules/registries";
|
import { registries } from "modules/registries";
|
||||||
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
import { StorageLocalAdapter } from "adapter/node/storage/StorageLocalAdapter";
|
||||||
import { AppMedia } from "../../src/media/AppMedia";
|
import { AppMedia } from "../../src/media/AppMedia";
|
||||||
import { moduleTestSuite } from "./module-test-suite";
|
import { moduleTestSuite } from "./module-test-suite";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("AppMedia", () => {
|
describe("AppMedia", () => {
|
||||||
test.skip("...", () => {
|
test.skip("...", () => {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { it, expect, describe, beforeAll, afterAll } from "bun:test";
|
import { it, expect, describe } from "bun:test";
|
||||||
import { DbModuleManager } from "modules/db/DbModuleManager";
|
import { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
import { getDummyConnection } from "../helper";
|
import { getDummyConnection } from "../helper";
|
||||||
import { TABLE_NAME } from "modules/db/migrations";
|
import { TABLE_NAME } from "modules/db/migrations";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("DbModuleManager", () => {
|
describe("DbModuleManager", () => {
|
||||||
it("should extract secrets", async () => {
|
it("should extract secrets", async () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { s, stripMark } from "core/utils/schema";
|
|||||||
import { Connection } from "data/connection/Connection";
|
import { Connection } from "data/connection/Connection";
|
||||||
import { entity, text } from "data/prototype";
|
import { entity, text } from "data/prototype";
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("ModuleManager", async () => {
|
describe("ModuleManager", async () => {
|
||||||
@@ -82,6 +82,7 @@ describe("ModuleManager", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as any;
|
} as any;
|
||||||
|
//const { version, ...json } = mm.toJSON() as any;
|
||||||
|
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const db = dummyConnection.kysely;
|
const db = dummyConnection.kysely;
|
||||||
@@ -96,6 +97,10 @@ describe("ModuleManager", async () => {
|
|||||||
|
|
||||||
await mm2.build();
|
await mm2.build();
|
||||||
|
|
||||||
|
/* console.log({
|
||||||
|
json,
|
||||||
|
configs: mm2.configs(),
|
||||||
|
}); */
|
||||||
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
//expect(stripMark(json)).toEqual(stripMark(mm2.configs()));
|
||||||
expect(mm2.configs().data.entities?.test).toBeDefined();
|
expect(mm2.configs().data.entities?.test).toBeDefined();
|
||||||
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
expect(mm2.configs().data.entities?.test?.fields?.content).toBeDefined();
|
||||||
@@ -223,6 +228,8 @@ describe("ModuleManager", async () => {
|
|||||||
const c = getDummyConnection();
|
const c = getDummyConnection();
|
||||||
const mm = new ModuleManager(c.dummyConnection);
|
const mm = new ModuleManager(c.dummyConnection);
|
||||||
await mm.build();
|
await mm.build();
|
||||||
|
console.log("==".repeat(30));
|
||||||
|
console.log("");
|
||||||
const json = mm.configs();
|
const json = mm.configs();
|
||||||
|
|
||||||
const c2 = getDummyConnection();
|
const c2 = getDummyConnection();
|
||||||
@@ -268,6 +275,7 @@ describe("ModuleManager", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async build() {
|
override async build() {
|
||||||
|
//console.log("building FailingModule", this.config);
|
||||||
if (this.config.value && this.config.value < 0) {
|
if (this.config.value && this.config.value < 0) {
|
||||||
throw new Error("value must be positive, given: " + this.config.value);
|
throw new Error("value must be positive, given: " + this.config.value);
|
||||||
}
|
}
|
||||||
@@ -288,6 +296,9 @@ describe("ModuleManager", async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beforeEach(() => disableConsoleLog(["log", "warn", "error"]));
|
||||||
|
afterEach(enableConsoleLog);
|
||||||
|
|
||||||
test("it builds", async () => {
|
test("it builds", async () => {
|
||||||
const { dummyConnection } = getDummyConnection();
|
const { dummyConnection } = getDummyConnection();
|
||||||
const mm = new TestModuleManager(dummyConnection);
|
const mm = new TestModuleManager(dummyConnection);
|
||||||
|
|||||||
+1
-12
@@ -3,17 +3,7 @@ import c from "picocolors";
|
|||||||
import { formatNumber } from "bknd/utils";
|
import { formatNumber } from "bknd/utils";
|
||||||
|
|
||||||
const deps = Object.keys(pkg.dependencies);
|
const deps = Object.keys(pkg.dependencies);
|
||||||
const external = [
|
const external = ["jsonv-ts/*", "wrangler", "bknd", "bknd/*", ...deps];
|
||||||
"jsonv-ts/*",
|
|
||||||
"wrangler",
|
|
||||||
"bknd",
|
|
||||||
"bknd/*",
|
|
||||||
"@vitejs/plugin-react",
|
|
||||||
"vite",
|
|
||||||
"@tailwindcss/vite",
|
|
||||||
"@cloudflare/vite-plugin",
|
|
||||||
...deps,
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/cli/index.ts"],
|
entrypoints: ["./src/cli/index.ts"],
|
||||||
@@ -21,7 +11,6 @@ const result = await Bun.build({
|
|||||||
outdir: "./dist/cli",
|
outdir: "./dist/cli",
|
||||||
env: "PUBLIC_*",
|
env: "PUBLIC_*",
|
||||||
minify: true,
|
minify: true,
|
||||||
banner: `const __originalLog=console.log;console.log=(...o)=>{const n=o[0];"string"==typeof n&&n.includes("[dotenv@")||__originalLog.apply(console,o)};`,
|
|
||||||
external,
|
external,
|
||||||
define: {
|
define: {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
|
|||||||
+11
-10
@@ -1,3 +1,4 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noConsole: . */
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import * as tsup from "tsup";
|
import * as tsup from "tsup";
|
||||||
import pkg from "./package.json" with { type: "json" };
|
import pkg from "./package.json" with { type: "json" };
|
||||||
@@ -186,9 +187,6 @@ async function buildUiElements() {
|
|||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
"ui/client",
|
"ui/client",
|
||||||
"bknd",
|
|
||||||
/^bknd\/.*/,
|
|
||||||
"wouter",
|
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
@@ -268,14 +266,9 @@ async function buildAdapters() {
|
|||||||
|
|
||||||
// specific adatpers
|
// specific adatpers
|
||||||
tsup.build(baseConfig("react-router")),
|
tsup.build(baseConfig("react-router")),
|
||||||
tsup.build(
|
|
||||||
baseConfig("browser", {
|
|
||||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
tsup.build(
|
tsup.build(
|
||||||
baseConfig("bun", {
|
baseConfig("bun", {
|
||||||
external: [/^bun\:.*/],
|
external: [/^bun:.*/],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
tsup.build(baseConfig("astro")),
|
tsup.build(baseConfig("astro")),
|
||||||
@@ -294,6 +287,14 @@ async function buildAdapters() {
|
|||||||
external: [/bknd/, "wrangler", "node:process"],
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
tsup.build(
|
||||||
|
baseConfig("cloudflare/vite", {
|
||||||
|
target: "esnext",
|
||||||
|
entry: ["src/adapter/cloudflare/vite.ts"],
|
||||||
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("vite"),
|
||||||
@@ -330,7 +331,7 @@ async function buildAdapters() {
|
|||||||
entry: ["src/adapter/sqlite/bun.ts"],
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
outDir: "dist/adapter/sqlite",
|
outDir: "dist/adapter/sqlite",
|
||||||
metafile: false,
|
metafile: false,
|
||||||
external: [/^bun\:.*/],
|
external: [/^bun:.*/],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-15
@@ -3,7 +3,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"version": "0.20.0-rc.1",
|
"version": "0.19.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": {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/bknd-io/bknd/issues"
|
"url": "https://github.com/bknd-io/bknd/issues"
|
||||||
},
|
},
|
||||||
"packageManager": "bun@1.3.3",
|
"packageManager": "bun@1.3.1",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.13"
|
"node": ">=22.13"
|
||||||
},
|
},
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"hono": "4.10.4",
|
"hono": "4.10.4",
|
||||||
"json-schema-library": "10.0.0-rc7",
|
"json-schema-library": "10.0.0-rc7",
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
"jsonv-ts": "^0.10.1",
|
"jsonv-ts": "0.9.3",
|
||||||
"kysely": "0.28.8",
|
"kysely": "0.28.8",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"oauth4webapi": "^2.11.1",
|
"oauth4webapi": "^2.11.1",
|
||||||
@@ -80,7 +80,6 @@
|
|||||||
"@aws-sdk/client-s3": "^3.922.0",
|
"@aws-sdk/client-s3": "^3.922.0",
|
||||||
"@bluwy/giget-core": "^0.1.6",
|
"@bluwy/giget-core": "^0.1.6",
|
||||||
"@clack/prompts": "^0.11.0",
|
"@clack/prompts": "^0.11.0",
|
||||||
"@cloudflare/vite-plugin": "^1.15.3",
|
|
||||||
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
"@cloudflare/vitest-pool-workers": "^0.10.4",
|
||||||
"@cloudflare/workers-types": "^4.20251014.0",
|
"@cloudflare/workers-types": "^4.20251014.0",
|
||||||
"@dagrejs/dagre": "^1.1.4",
|
"@dagrejs/dagre": "^1.1.4",
|
||||||
@@ -100,7 +99,6 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/pg": "^8.15.6",
|
|
||||||
"@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": "^5.1.0",
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
@@ -112,17 +110,14 @@
|
|||||||
"jotai": "^2.12.2",
|
"jotai": "^2.12.2",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"kysely-generic-sqlite": "^1.2.1",
|
"kysely-generic-sqlite": "^1.2.1",
|
||||||
"kysely-postgres-js": "^2.0.0",
|
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"libsql-stateless-easy": "^1.8.0",
|
"libsql-stateless-easy": "^1.8.0",
|
||||||
"miniflare": "^4.20251011.2",
|
"miniflare": "^4.20251011.2",
|
||||||
"open": "^10.2.0",
|
"open": "^10.2.0",
|
||||||
"openapi-types": "^12.1.3",
|
"openapi-types": "^12.1.3",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"postgres": "^3.4.7",
|
|
||||||
"posthog-js-lite": "^3.6.0",
|
"posthog-js-lite": "^3.6.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -130,7 +125,6 @@
|
|||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"react-json-view-lite": "^2.5.0",
|
"react-json-view-lite": "^2.5.0",
|
||||||
"sql-formatter": "^15.6.10",
|
"sql-formatter": "^15.6.10",
|
||||||
"sqlocal": "^0.16.0",
|
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -143,7 +137,7 @@
|
|||||||
"vite-tsconfig-paths": "^5.1.4",
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"vitest": "3.0.9",
|
"vitest": "3.0.9",
|
||||||
"wouter": "^3.7.1",
|
"wouter": "^3.7.1",
|
||||||
"wrangler": "^4.52.1"
|
"wrangler": "^4.45.4"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@hono/node-server": "^1.19.6"
|
"@hono/node-server": "^1.19.6"
|
||||||
@@ -220,6 +214,11 @@
|
|||||||
"import": "./dist/adapter/cloudflare/proxy.js",
|
"import": "./dist/adapter/cloudflare/proxy.js",
|
||||||
"require": "./dist/adapter/cloudflare/proxy.js"
|
"require": "./dist/adapter/cloudflare/proxy.js"
|
||||||
},
|
},
|
||||||
|
"./adapter/cloudflare/vite": {
|
||||||
|
"types": "./dist/types/adapter/cloudflare/vite.d.ts",
|
||||||
|
"import": "./dist/adapter/cloudflare/vite.js",
|
||||||
|
"require": "./dist/adapter/cloudflare/vite.js"
|
||||||
|
},
|
||||||
"./adapter": {
|
"./adapter": {
|
||||||
"types": "./dist/types/adapter/index.d.ts",
|
"types": "./dist/types/adapter/index.d.ts",
|
||||||
"import": "./dist/adapter/index.js"
|
"import": "./dist/adapter/index.js"
|
||||||
@@ -259,11 +258,6 @@
|
|||||||
"import": "./dist/adapter/aws/index.js",
|
"import": "./dist/adapter/aws/index.js",
|
||||||
"require": "./dist/adapter/aws/index.js"
|
"require": "./dist/adapter/aws/index.js"
|
||||||
},
|
},
|
||||||
"./adapter/browser": {
|
|
||||||
"types": "./dist/types/adapter/browser/index.d.ts",
|
|
||||||
"import": "./dist/adapter/browser/index.js",
|
|
||||||
"require": "./dist/adapter/browser/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",
|
||||||
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
"./dist/manifest.json": "./dist/static/.vite/manifest.json",
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ export class Api {
|
|||||||
private token?: string;
|
private token?: string;
|
||||||
private user?: TApiUser;
|
private user?: TApiUser;
|
||||||
private verified = false;
|
private verified = false;
|
||||||
public token_transport: "header" | "cookie" | "none" = "header";
|
private token_transport: "header" | "cookie" | "none" = "header";
|
||||||
|
|
||||||
public system!: SystemApi;
|
public system!: SystemApi;
|
||||||
public data!: DataApi;
|
public data!: DataApi;
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
import {
|
|
||||||
createContext,
|
|
||||||
lazy,
|
|
||||||
Suspense,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { checksum } from "bknd/utils";
|
|
||||||
import { App, registries, sqlocal, type BkndConfig } from "bknd";
|
|
||||||
import { Route, Router, Switch } from "wouter";
|
|
||||||
import { ClientProvider } from "bknd/client";
|
|
||||||
import { SQLocalKysely } from "sqlocal/kysely";
|
|
||||||
import type { ClientConfig, DatabasePath } from "sqlocal";
|
|
||||||
import { OpfsStorageAdapter } from "bknd/adapter/browser";
|
|
||||||
import type { BkndAdminConfig } from "bknd/ui";
|
|
||||||
|
|
||||||
const Admin = lazy(() =>
|
|
||||||
Promise.all([
|
|
||||||
import("bknd/ui"),
|
|
||||||
// @ts-ignore
|
|
||||||
import("bknd/dist/styles.css"),
|
|
||||||
]).then(([mod]) => ({
|
|
||||||
default: mod.Admin,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
function safeViewTransition(fn: () => void) {
|
|
||||||
if (document.startViewTransition) {
|
|
||||||
document.startViewTransition(fn);
|
|
||||||
} else {
|
|
||||||
fn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BrowserBkndConfig<Args = ImportMetaEnv> = Omit<
|
|
||||||
BkndConfig<Args>,
|
|
||||||
"connection" | "app"
|
|
||||||
> & {
|
|
||||||
adminConfig?: BkndAdminConfig;
|
|
||||||
connection?: ClientConfig | DatabasePath;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BkndBrowserAppProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
header?: ReactNode;
|
|
||||||
loading?: ReactNode;
|
|
||||||
notFound?: ReactNode;
|
|
||||||
} & BrowserBkndConfig;
|
|
||||||
|
|
||||||
const BkndBrowserAppContext = createContext<{
|
|
||||||
app: App;
|
|
||||||
hash: string;
|
|
||||||
}>(undefined!);
|
|
||||||
|
|
||||||
export function BkndBrowserApp({
|
|
||||||
children,
|
|
||||||
adminConfig,
|
|
||||||
header,
|
|
||||||
loading,
|
|
||||||
notFound,
|
|
||||||
...config
|
|
||||||
}: BkndBrowserAppProps) {
|
|
||||||
const [app, setApp] = useState<App | undefined>(undefined);
|
|
||||||
const [hash, setHash] = useState<string>("");
|
|
||||||
const adminRoutePath = (adminConfig?.basepath ?? "") + "/*?";
|
|
||||||
|
|
||||||
async function onBuilt(app: App) {
|
|
||||||
safeViewTransition(async () => {
|
|
||||||
setApp(app);
|
|
||||||
setHash(await checksum(app.toJSON()));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setup({ ...config, adminConfig })
|
|
||||||
.then((app) => onBuilt(app as any))
|
|
||||||
.catch(console.error);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
return (
|
|
||||||
loading ?? (
|
|
||||||
<Center>
|
|
||||||
<span style={{ opacity: 0.2 }}>Loading...</span>
|
|
||||||
</Center>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BkndBrowserAppContext.Provider value={{ app, hash }}>
|
|
||||||
<ClientProvider storage={window.localStorage} fetcher={app.server.request}>
|
|
||||||
{header}
|
|
||||||
<Router key={hash}>
|
|
||||||
<Switch>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<Route path={adminRoutePath}>
|
|
||||||
<Suspense>
|
|
||||||
<Admin config={adminConfig} />
|
|
||||||
</Suspense>
|
|
||||||
</Route>
|
|
||||||
<Route path="*">
|
|
||||||
{notFound ?? (
|
|
||||||
<Center style={{ fontSize: "48px", fontFamily: "monospace" }}>404</Center>
|
|
||||||
)}
|
|
||||||
</Route>
|
|
||||||
</Switch>
|
|
||||||
</Router>
|
|
||||||
</ClientProvider>
|
|
||||||
</BkndBrowserAppContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApp() {
|
|
||||||
return useContext(BkndBrowserAppContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Center = (props: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
{...props}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
minHeight: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
...(props.style ?? {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
async function setup(config: BrowserBkndConfig = {}) {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
|
|
||||||
registries.media.register("opfs", OpfsStorageAdapter);
|
|
||||||
|
|
||||||
const app = App.create({
|
|
||||||
...config,
|
|
||||||
// @ts-ignore
|
|
||||||
connection: sqlocal(new SQLocalKysely(config.connection ?? ":localStorage:")),
|
|
||||||
});
|
|
||||||
|
|
||||||
await config.beforeBuild?.(app);
|
|
||||||
await app.build({ sync: true });
|
|
||||||
await config.onBuilt?.(app);
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, beforeAll, vi, afterAll, spyOn } from "bun:test";
|
|
||||||
import { OpfsStorageAdapter } from "./OpfsStorageAdapter";
|
|
||||||
// @ts-ignore
|
|
||||||
import { assetsPath } from "../../../__test__/helper";
|
|
||||||
import { adapterTestSuite } from "media/storage/adapters/adapter-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
import { MockFileSystemDirectoryHandle } from "adapter/browser/mock";
|
|
||||||
|
|
||||||
describe("OpfsStorageAdapter", async () => {
|
|
||||||
let mockRoot: MockFileSystemDirectoryHandle;
|
|
||||||
let testSuiteAdapter: OpfsStorageAdapter;
|
|
||||||
|
|
||||||
const _mock = spyOn(global, "navigator");
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
// mock navigator.storage.getDirectory()
|
|
||||||
mockRoot = new MockFileSystemDirectoryHandle("opfs-root");
|
|
||||||
const mockNavigator = {
|
|
||||||
storage: {
|
|
||||||
getDirectory: vi.fn().mockResolvedValue(mockRoot),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// @ts-ignore
|
|
||||||
_mock.mockReturnValue(mockNavigator);
|
|
||||||
testSuiteAdapter = new OpfsStorageAdapter();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
_mock.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
const file = Bun.file(`${assetsPath}/image.png`);
|
|
||||||
await adapterTestSuite(bunTestRunner, () => testSuiteAdapter, file);
|
|
||||||
});
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import type { FileBody, FileListObject, FileMeta, FileUploadPayload } from "bknd";
|
|
||||||
import { StorageAdapter, guessMimeType } from "bknd";
|
|
||||||
import { parse, s, isFile, isBlob } from "bknd/utils";
|
|
||||||
|
|
||||||
export const opfsAdapterConfig = s.object(
|
|
||||||
{
|
|
||||||
root: s.string({ default: "" }).optional(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "OPFS",
|
|
||||||
description: "Origin Private File System storage",
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
export type OpfsAdapterConfig = s.Static<typeof opfsAdapterConfig>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Storage adapter for OPFS (Origin Private File System)
|
|
||||||
* Provides browser-based file storage using the File System Access API
|
|
||||||
*/
|
|
||||||
export class OpfsStorageAdapter extends StorageAdapter {
|
|
||||||
private config: OpfsAdapterConfig;
|
|
||||||
private rootPromise: Promise<FileSystemDirectoryHandle>;
|
|
||||||
|
|
||||||
constructor(config: Partial<OpfsAdapterConfig> = {}) {
|
|
||||||
super();
|
|
||||||
this.config = parse(opfsAdapterConfig, config);
|
|
||||||
this.rootPromise = this.initializeRoot();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initializeRoot(): Promise<FileSystemDirectoryHandle> {
|
|
||||||
const opfsRoot = await navigator.storage.getDirectory();
|
|
||||||
if (!this.config.root) {
|
|
||||||
return opfsRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// navigate to or create nested directory structure
|
|
||||||
const parts = this.config.root.split("/").filter(Boolean);
|
|
||||||
let current = opfsRoot;
|
|
||||||
for (const part of parts) {
|
|
||||||
current = await current.getDirectoryHandle(part, { create: true });
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSchema() {
|
|
||||||
return opfsAdapterConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
getName(): string {
|
|
||||||
return "opfs";
|
|
||||||
}
|
|
||||||
|
|
||||||
async listObjects(prefix?: string): Promise<FileListObject[]> {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const files: FileListObject[] = [];
|
|
||||||
|
|
||||||
for await (const [name, handle] of root.entries()) {
|
|
||||||
if (handle.kind === "file") {
|
|
||||||
if (!prefix || name.startsWith(prefix)) {
|
|
||||||
const file = await (handle as FileSystemFileHandle).getFile();
|
|
||||||
files.push({
|
|
||||||
key: name,
|
|
||||||
last_modified: new Date(file.lastModified),
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async computeEtagFromArrayBuffer(buffer: ArrayBuffer): Promise<string> {
|
|
||||||
const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
|
|
||||||
// wrap the hex string in quotes for ETag format
|
|
||||||
return `"${hashHex}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async putObject(key: string, body: FileBody): Promise<string | FileUploadPayload> {
|
|
||||||
if (body === null) {
|
|
||||||
throw new Error("Body is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const fileHandle = await root.getFileHandle(key, { create: true });
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
|
|
||||||
try {
|
|
||||||
let contentBuffer: ArrayBuffer;
|
|
||||||
|
|
||||||
if (isFile(body)) {
|
|
||||||
contentBuffer = await body.arrayBuffer();
|
|
||||||
await writable.write(contentBuffer);
|
|
||||||
} else if (body instanceof ReadableStream) {
|
|
||||||
const chunks: Uint8Array[] = [];
|
|
||||||
const reader = body.getReader();
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
chunks.push(value);
|
|
||||||
await writable.write(value);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
// compute total size and combine chunks for etag
|
|
||||||
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
||||||
const combined = new Uint8Array(totalSize);
|
|
||||||
let offset = 0;
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
combined.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
contentBuffer = combined.buffer;
|
|
||||||
} else if (isBlob(body)) {
|
|
||||||
contentBuffer = await (body as Blob).arrayBuffer();
|
|
||||||
await writable.write(contentBuffer);
|
|
||||||
} else {
|
|
||||||
// body is ArrayBuffer or ArrayBufferView
|
|
||||||
if (ArrayBuffer.isView(body)) {
|
|
||||||
const view = body as ArrayBufferView;
|
|
||||||
contentBuffer = view.buffer.slice(
|
|
||||||
view.byteOffset,
|
|
||||||
view.byteOffset + view.byteLength,
|
|
||||||
) as ArrayBuffer;
|
|
||||||
} else {
|
|
||||||
contentBuffer = body as ArrayBuffer;
|
|
||||||
}
|
|
||||||
await writable.write(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
await writable.close();
|
|
||||||
return await this.computeEtagFromArrayBuffer(contentBuffer);
|
|
||||||
} catch (error) {
|
|
||||||
await writable.abort();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteObject(key: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
await root.removeEntry(key);
|
|
||||||
} catch {
|
|
||||||
// file doesn't exist, which is fine
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async objectExists(key: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
await root.getFileHandle(key);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseRangeHeader(
|
|
||||||
rangeHeader: string,
|
|
||||||
fileSize: number,
|
|
||||||
): { start: number; end: number } | null {
|
|
||||||
// parse "bytes=start-end" format
|
|
||||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const [, startStr, endStr] = match;
|
|
||||||
let start = startStr ? Number.parseInt(startStr, 10) : 0;
|
|
||||||
let end = endStr ? Number.parseInt(endStr, 10) : fileSize - 1;
|
|
||||||
|
|
||||||
// handle suffix-byte-range-spec (e.g., "bytes=-500")
|
|
||||||
if (!startStr && endStr) {
|
|
||||||
start = Math.max(0, fileSize - Number.parseInt(endStr, 10));
|
|
||||||
end = fileSize - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate range
|
|
||||||
if (start < 0 || end >= fileSize || start > end) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { start, end };
|
|
||||||
}
|
|
||||||
|
|
||||||
async getObject(key: string, headers: Headers): Promise<Response> {
|
|
||||||
try {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const fileHandle = await root.getFileHandle(key);
|
|
||||||
const file = await fileHandle.getFile();
|
|
||||||
const fileSize = file.size;
|
|
||||||
const mimeType = guessMimeType(key);
|
|
||||||
|
|
||||||
const responseHeaders = new Headers({
|
|
||||||
"Accept-Ranges": "bytes",
|
|
||||||
"Content-Type": mimeType || "application/octet-stream",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rangeHeader = headers.get("range");
|
|
||||||
|
|
||||||
if (rangeHeader) {
|
|
||||||
const range = this.parseRangeHeader(rangeHeader, fileSize);
|
|
||||||
|
|
||||||
if (!range) {
|
|
||||||
// invalid range - return 416 Range Not Satisfiable
|
|
||||||
responseHeaders.set("Content-Range", `bytes */${fileSize}`);
|
|
||||||
return new Response("", {
|
|
||||||
status: 416,
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { start, end } = range;
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
|
||||||
const chunk = arrayBuffer.slice(start, end + 1);
|
|
||||||
|
|
||||||
responseHeaders.set("Content-Range", `bytes ${start}-${end}/${fileSize}`);
|
|
||||||
responseHeaders.set("Content-Length", chunk.byteLength.toString());
|
|
||||||
|
|
||||||
return new Response(chunk, {
|
|
||||||
status: 206, // Partial Content
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// normal request - return entire file
|
|
||||||
const content = await file.arrayBuffer();
|
|
||||||
responseHeaders.set("Content-Length", content.byteLength.toString());
|
|
||||||
|
|
||||||
return new Response(content, {
|
|
||||||
status: 200,
|
|
||||||
headers: responseHeaders,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// handle file reading errors
|
|
||||||
return new Response("", { status: 404 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getObjectUrl(_key: string): string {
|
|
||||||
throw new Error("Method not implemented.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async getObjectMeta(key: string): Promise<FileMeta> {
|
|
||||||
const root = await this.rootPromise;
|
|
||||||
const fileHandle = await root.getFileHandle(key);
|
|
||||||
const file = await fileHandle.getFile();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: guessMimeType(key) || "application/octet-stream",
|
|
||||||
size: file.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(_secrets?: boolean) {
|
|
||||||
return {
|
|
||||||
type: this.getName(),
|
|
||||||
config: this.config,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./OpfsStorageAdapter";
|
|
||||||
export * from "./BkndBrowserApp";
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
// mock OPFS API for testing
|
|
||||||
class MockFileSystemFileHandle {
|
|
||||||
kind: "file" = "file";
|
|
||||||
name: string;
|
|
||||||
private content: ArrayBuffer;
|
|
||||||
private lastModified: number;
|
|
||||||
|
|
||||||
constructor(name: string, content: ArrayBuffer = new ArrayBuffer(0)) {
|
|
||||||
this.name = name;
|
|
||||||
this.content = content;
|
|
||||||
this.lastModified = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFile(): Promise<File> {
|
|
||||||
return new File([this.content], this.name, {
|
|
||||||
lastModified: this.lastModified,
|
|
||||||
type: this.guessMimeType(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async createWritable(): Promise<FileSystemWritableFileStream> {
|
|
||||||
const handle = this;
|
|
||||||
return {
|
|
||||||
async write(data: any) {
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
handle.content = data;
|
|
||||||
} else if (ArrayBuffer.isView(data)) {
|
|
||||||
handle.content = data.buffer.slice(
|
|
||||||
data.byteOffset,
|
|
||||||
data.byteOffset + data.byteLength,
|
|
||||||
) as ArrayBuffer;
|
|
||||||
} else if (data instanceof Blob) {
|
|
||||||
handle.content = await data.arrayBuffer();
|
|
||||||
}
|
|
||||||
handle.lastModified = Date.now();
|
|
||||||
},
|
|
||||||
async close() {},
|
|
||||||
async abort() {},
|
|
||||||
async seek(_position: number) {},
|
|
||||||
async truncate(_size: number) {},
|
|
||||||
} as FileSystemWritableFileStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
private guessMimeType(): string {
|
|
||||||
const ext = this.name.split(".").pop()?.toLowerCase();
|
|
||||||
const mimeTypes: Record<string, string> = {
|
|
||||||
png: "image/png",
|
|
||||||
jpg: "image/jpeg",
|
|
||||||
jpeg: "image/jpeg",
|
|
||||||
gif: "image/gif",
|
|
||||||
webp: "image/webp",
|
|
||||||
svg: "image/svg+xml",
|
|
||||||
txt: "text/plain",
|
|
||||||
json: "application/json",
|
|
||||||
pdf: "application/pdf",
|
|
||||||
};
|
|
||||||
return mimeTypes[ext || ""] || "application/octet-stream";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MockFileSystemDirectoryHandle {
|
|
||||||
kind: "directory" = "directory";
|
|
||||||
name: string;
|
|
||||||
private files: Map<string, MockFileSystemFileHandle> = new Map();
|
|
||||||
private directories: Map<string, MockFileSystemDirectoryHandle> = new Map();
|
|
||||||
|
|
||||||
constructor(name: string = "root") {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFileHandle(
|
|
||||||
name: string,
|
|
||||||
options?: FileSystemGetFileOptions,
|
|
||||||
): Promise<FileSystemFileHandle> {
|
|
||||||
if (this.files.has(name)) {
|
|
||||||
return this.files.get(name) as any;
|
|
||||||
}
|
|
||||||
if (options?.create) {
|
|
||||||
const handle = new MockFileSystemFileHandle(name);
|
|
||||||
this.files.set(name, handle);
|
|
||||||
return handle as any;
|
|
||||||
}
|
|
||||||
throw new Error(`File not found: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDirectoryHandle(
|
|
||||||
name: string,
|
|
||||||
options?: FileSystemGetDirectoryOptions,
|
|
||||||
): Promise<FileSystemDirectoryHandle> {
|
|
||||||
if (this.directories.has(name)) {
|
|
||||||
return this.directories.get(name) as any;
|
|
||||||
}
|
|
||||||
if (options?.create) {
|
|
||||||
const handle = new MockFileSystemDirectoryHandle(name);
|
|
||||||
this.directories.set(name, handle);
|
|
||||||
return handle as any;
|
|
||||||
}
|
|
||||||
throw new Error(`Directory not found: ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeEntry(name: string, _options?: FileSystemRemoveOptions): Promise<void> {
|
|
||||||
this.files.delete(name);
|
|
||||||
this.directories.delete(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
|
|
||||||
for (const [name, handle] of this.files) {
|
|
||||||
yield [name, handle as any];
|
|
||||||
}
|
|
||||||
for (const [name, handle] of this.directories) {
|
|
||||||
yield [name, handle as any];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async *keys(): AsyncIterableIterator<string> {
|
|
||||||
for (const name of this.files.keys()) {
|
|
||||||
yield name;
|
|
||||||
}
|
|
||||||
for (const name of this.directories.keys()) {
|
|
||||||
yield name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async *values(): AsyncIterableIterator<FileSystemHandle> {
|
|
||||||
for (const handle of this.files.values()) {
|
|
||||||
yield handle as any;
|
|
||||||
}
|
|
||||||
for (const handle of this.directories.values()) {
|
|
||||||
yield handle as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]> {
|
|
||||||
return this.entries();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,6 @@ export function createHandler<Env = BunEnv>(
|
|||||||
|
|
||||||
export function serve<Env = BunEnv>(
|
export function serve<Env = BunEnv>(
|
||||||
{
|
{
|
||||||
app,
|
|
||||||
distPath,
|
distPath,
|
||||||
connection,
|
connection,
|
||||||
config: _config,
|
config: _config,
|
||||||
@@ -63,7 +62,6 @@ export function serve<Env = BunEnv>(
|
|||||||
port,
|
port,
|
||||||
fetch: createHandler(
|
fetch: createHandler(
|
||||||
{
|
{
|
||||||
app,
|
|
||||||
connection,
|
connection,
|
||||||
config: _config,
|
config: _config,
|
||||||
options,
|
options,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { inspect } from "node:util";
|
|
||||||
|
|
||||||
export type BindingTypeMap = {
|
export type BindingTypeMap = {
|
||||||
D1Database: D1Database;
|
D1Database: D1Database;
|
||||||
KVNamespace: KVNamespace;
|
KVNamespace: KVNamespace;
|
||||||
@@ -10,21 +8,40 @@ export type BindingTypeMap = {
|
|||||||
export type GetBindingType = keyof BindingTypeMap;
|
export type GetBindingType = keyof BindingTypeMap;
|
||||||
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
export type BindingMap<T extends GetBindingType> = { key: string; value: BindingTypeMap[T] };
|
||||||
|
|
||||||
|
export const bindingMatchers = {
|
||||||
|
// D1Database instance doesn't stringify to [object D1Database] (yet)
|
||||||
|
D1Database: (value: object): value is D1Database => {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
if (String(value) === "[object D1Database]") return true;
|
||||||
|
if (Object.keys(value).length !== 2) return false;
|
||||||
|
return ["alwaysPrimarySession", "fetcher"].every((key) => key in value);
|
||||||
|
},
|
||||||
|
KVNamespace: (value: object): value is KVNamespace =>
|
||||||
|
String(value) === "[object KvNamespace]" && Object.keys(value).length === 0,
|
||||||
|
DurableObjectNamespace: (value: object): value is DurableObjectNamespace =>
|
||||||
|
String(value) === "[object DurableObjectNamespace]" && Object.keys(value).length === 0,
|
||||||
|
R2Bucket: (value: object): value is R2Bucket =>
|
||||||
|
String(value) === "[object R2Bucket]" && Object.keys(value).length === 0,
|
||||||
|
};
|
||||||
|
|
||||||
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
export function getBindings<T extends GetBindingType>(env: any, type: T): BindingMap<T>[] {
|
||||||
const bindings: BindingMap<T>[] = [];
|
const bindings: BindingMap<T>[] = [];
|
||||||
|
if (!(type in bindingMatchers)) {
|
||||||
|
console.error(`No binding matcher for type ${type}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matcher = bindingMatchers[type];
|
||||||
|
|
||||||
for (const key in env) {
|
for (const key in env) {
|
||||||
try {
|
const value = env[key];
|
||||||
if (
|
if (typeof value !== "object" || value === null) continue;
|
||||||
(env[key] as any).constructor.name === type ||
|
if (!matcher(value)) continue;
|
||||||
String(env[key]) === `[object ${type}]` ||
|
|
||||||
inspect(env[key]).includes(type)
|
bindings.push({
|
||||||
) {
|
key,
|
||||||
bindings.push({
|
value,
|
||||||
key,
|
} as any);
|
||||||
value: env[key] as BindingTypeMap[T],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
return bindings;
|
return bindings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, afterAll, test, expect, beforeAll } from "vitest";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
import { getBindings, type GetBindingType } from "./bindings";
|
||||||
|
|
||||||
|
let mf: Miniflare | undefined;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
script: `import { DurableObject } from "cloudflare:workers";
|
||||||
|
export default { async fetch() { return new Response(null); } }
|
||||||
|
export class TestObject extends DurableObject {}
|
||||||
|
export class TestObject2 extends DurableObject {}`,
|
||||||
|
r2Buckets: ["BUCKET"],
|
||||||
|
d1Databases: ["DB"],
|
||||||
|
kvNamespaces: ["KV"],
|
||||||
|
durableObjects: {
|
||||||
|
DO_SQLITE: { className: "TestObject", useSQLite: true },
|
||||||
|
DO_KV: { className: "TestObject2" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
mf?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bindings", () => {
|
||||||
|
test("extracts", async () => {
|
||||||
|
const env = {
|
||||||
|
...(await mf!.getBindings()),
|
||||||
|
some: "string",
|
||||||
|
another: 123,
|
||||||
|
array: [1, 2, 3],
|
||||||
|
};
|
||||||
|
|
||||||
|
const $getBindingKeys = (type: GetBindingType) => {
|
||||||
|
const bindings = getBindings(env, type);
|
||||||
|
const keys = bindings?.map((b) => b.key);
|
||||||
|
if (!keys || keys.length === 0) {
|
||||||
|
console.error(`No ${type} binding found`, { bindings });
|
||||||
|
throw new Error(`No ${type} binding found`);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect($getBindingKeys("D1Database")).toEqual(["DB"]);
|
||||||
|
expect($getBindingKeys("KVNamespace")).toEqual(["KV"]);
|
||||||
|
expect($getBindingKeys("DurableObjectNamespace")).toEqual(["DO_SQLITE", "DO_KV"]);
|
||||||
|
expect($getBindingKeys("R2Bucket")).toEqual(["BUCKET"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { RuntimeBkndConfig } 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 type { App, MaybePromise } from "bknd";
|
import type { MaybePromise } from "bknd";
|
||||||
import { $console } from "bknd/utils";
|
import { $console } from "bknd/utils";
|
||||||
import { createRuntimeApp } from "bknd/adapter";
|
import { createRuntimeApp } from "bknd/adapter";
|
||||||
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
import { registerAsyncsExecutionContext, makeConfig, type CloudflareContext } from "./config";
|
||||||
@@ -55,12 +55,8 @@ export async function createApp<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
// compatiblity
|
// compatiblity
|
||||||
export const getFresh = createApp;
|
export const getFresh = createApp;
|
||||||
|
|
||||||
let app: App | undefined;
|
|
||||||
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
||||||
config: CloudflareBkndConfig<Env> = {},
|
config: CloudflareBkndConfig<Env> = {},
|
||||||
serveOptions?: (args: Env) => {
|
|
||||||
warm?: boolean;
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
||||||
@@ -96,11 +92,8 @@ export function serve<Env extends CloudflareEnv = CloudflareEnv>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { warm } = serveOptions?.(env) ?? {};
|
const context = { request, env, ctx } as CloudflareContext<Env>;
|
||||||
if (!app || warm !== true) {
|
const app = await createApp(config, context);
|
||||||
const context = { request, env, ctx } as CloudflareContext<Env>;
|
|
||||||
app = await createApp(config, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app.fetch(request, env, ctx);
|
return app.fetch(request, env, ctx);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,10 +16,14 @@ export {
|
|||||||
type GetBindingType,
|
type GetBindingType,
|
||||||
type BindingMap,
|
type BindingMap,
|
||||||
} from "./bindings";
|
} from "./bindings";
|
||||||
export { constants, makeConfig, type CloudflareContext } from "./config";
|
export {
|
||||||
|
constants,
|
||||||
|
makeConfig,
|
||||||
|
type CloudflareContext,
|
||||||
|
registerAsyncsExecutionContext,
|
||||||
|
} from "./config";
|
||||||
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
export { StorageR2Adapter, registerMedia } from "./storage/StorageR2Adapter";
|
||||||
export { registries } from "bknd";
|
export { registries } from "bknd";
|
||||||
export { devFsVitePlugin, devFsWrite } from "./vite";
|
|
||||||
|
|
||||||
// for compatibility with old code
|
// for compatibility with old code
|
||||||
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
export function d1<DB extends D1Database | D1DatabaseSession = D1Database>(
|
||||||
|
|||||||
@@ -65,31 +65,37 @@ export function withPlatformProxy<Env extends CloudflareEnv>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...config,
|
||||||
|
beforeBuild: async (app, registries) => {
|
||||||
|
if (!use_proxy) return;
|
||||||
|
const env = await getEnv();
|
||||||
|
registerMedia(env, registries as any);
|
||||||
|
await config?.beforeBuild?.(app, registries);
|
||||||
|
},
|
||||||
|
bindings: async (env) => {
|
||||||
|
return (await config?.bindings?.(await getEnv(env))) || {};
|
||||||
|
},
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
app: async (_env) => {
|
app: async (_env) => {
|
||||||
const env = await getEnv(_env);
|
const env = await getEnv(_env);
|
||||||
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
|
const binding = use_proxy ? getBinding(env, "D1Database") : undefined;
|
||||||
const appConfig = typeof config.app === "function" ? await config.app(env) : config;
|
|
||||||
const connection =
|
|
||||||
use_proxy && binding
|
|
||||||
? d1Sqlite({
|
|
||||||
binding: binding.value as any,
|
|
||||||
})
|
|
||||||
: appConfig.connection;
|
|
||||||
|
|
||||||
return {
|
if (config?.app === undefined && use_proxy && binding) {
|
||||||
...appConfig,
|
return {
|
||||||
beforeBuild: async (app, registries) => {
|
connection: d1Sqlite({
|
||||||
if (!use_proxy) return;
|
binding: binding.value,
|
||||||
const env = await getEnv();
|
}),
|
||||||
registerMedia(env, registries as any);
|
};
|
||||||
await config?.beforeBuild?.(app, registries);
|
} else if (typeof config?.app === "function") {
|
||||||
},
|
const appConfig = await config?.app(env);
|
||||||
bindings: async (env) => {
|
if (binding) {
|
||||||
return (await config?.bindings?.(await getEnv(env))) || {};
|
appConfig.connection = d1Sqlite({
|
||||||
},
|
binding: binding.value,
|
||||||
connection,
|
}) as any;
|
||||||
};
|
}
|
||||||
|
return appConfig;
|
||||||
|
}
|
||||||
|
return config?.app || {};
|
||||||
},
|
},
|
||||||
} satisfies CloudflareBkndConfig<Env>;
|
} satisfies CloudflareBkndConfig<Env>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function registerMedia(
|
|||||||
env: Record<string, any>,
|
env: Record<string, any>,
|
||||||
registries: typeof $registries = $registries,
|
registries: typeof $registries = $registries,
|
||||||
) {
|
) {
|
||||||
|
if (registries.media.has("r2")) return;
|
||||||
const r2_bindings = getBindings(env, "R2Bucket");
|
const r2_bindings = getBindings(env, "R2Bucket");
|
||||||
|
|
||||||
registries.media.register(
|
registries.media.register(
|
||||||
@@ -172,7 +173,7 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
await this.bucket.delete(this.getKey(key));
|
await this.bucket.delete(this.getKey(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
getObjectUrl(key: string): string {
|
getObjectUrl(): string {
|
||||||
throw new Error("Method getObjectUrl not implemented.");
|
throw new Error("Method getObjectUrl not implemented.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ export class StorageR2Adapter extends StorageAdapter {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON(secrets?: boolean) {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
type: this.getName(),
|
type: this.getName(),
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// biome-ignore-all lint: .
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { writeFile as nodeWriteFile } from "node:fs/promises";
|
import { writeFile as nodeWriteFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
@@ -48,7 +49,6 @@ export function devFsVitePlugin({
|
|||||||
// Skip our own debug output
|
// Skip our own debug output
|
||||||
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
if (output.includes("[dev-fs-plugin]") || output.includes("[dev-fs-polyfill]")) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// biome-ignore lint/style/noArguments: <explanation>
|
|
||||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +178,6 @@ export function devFsVitePlugin({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
// biome-ignore lint:
|
|
||||||
return originalStdoutWrite.apply(process.stdout, arguments);
|
return originalStdoutWrite.apply(process.stdout, arguments);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ import type { AdminControllerOptions } from "modules/server/AdminController";
|
|||||||
import type { Manifest } from "vite";
|
import type { Manifest } from "vite";
|
||||||
|
|
||||||
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
export type BkndConfig<Args = any, Additional = {}> = Merge<
|
||||||
CreateAppConfig &
|
CreateAppConfig & {
|
||||||
Omit<Additional, "app"> & {
|
app?:
|
||||||
app?:
|
| Merge<Omit<BkndConfig, "app"> & Additional>
|
||||||
| Omit<BkndConfig<Args, Additional>, "app">
|
| ((args: Args) => MaybePromise<Merge<Omit<BkndConfig<Args>, "app"> & Additional>>);
|
||||||
| ((args: Args) => MaybePromise<Omit<BkndConfig<Args, Additional>, "app">>);
|
onBuilt?: (app: App) => MaybePromise<void>;
|
||||||
onBuilt?: (app: App) => MaybePromise<void>;
|
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
||||||
beforeBuild?: (app?: App, registries?: typeof $registries) => MaybePromise<void>;
|
buildConfig?: Parameters<App["build"]>[0];
|
||||||
buildConfig?: Parameters<App["build"]>[0];
|
} & Additional
|
||||||
}
|
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
export type FrameworkBkndConfig<Args = any> = BkndConfig<Args>;
|
||||||
@@ -69,11 +68,6 @@ export async function createAdapterApp<Config extends BkndConfig = BkndConfig, A
|
|||||||
if (Connection.isConnection(config.connection)) {
|
if (Connection.isConnection(config.connection)) {
|
||||||
connection = config.connection;
|
connection = config.connection;
|
||||||
} else {
|
} else {
|
||||||
if (connection) {
|
|
||||||
$console.warn(
|
|
||||||
"Connection is not a valid connection object, using default SQLite connection",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
const sqlite = (await import("bknd/adapter/sqlite")).sqlite;
|
||||||
const conf = appConfig.connection ?? { url: "file:data.db" };
|
const conf = appConfig.connection ?? { url: "file:data.db" };
|
||||||
connection = sqlite(conf) as any;
|
connection = sqlite(conf) as any;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function createApp<Env = NodeEnv>(
|
|||||||
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
|
path.resolve(distPath ?? relativeDistPath ?? "./node_modules/bknd/dist", "static"),
|
||||||
);
|
);
|
||||||
if (relativeDistPath) {
|
if (relativeDistPath) {
|
||||||
$console.warn("relativeDistPath is deprecated, please use distPath instead");
|
console.warn("relativeDistPath is deprecated, please use distPath instead");
|
||||||
}
|
}
|
||||||
|
|
||||||
registerLocalMediaAdapter();
|
registerLocalMediaAdapter();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe } from "vitest";
|
import { describe, beforeAll, afterAll } from "vitest";
|
||||||
import * as node from "./node.adapter";
|
import * as node from "./node.adapter";
|
||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { viTestRunner } from "adapter/node/vitest";
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import type { CliCommand } from "cli/types";
|
|
||||||
import { Option } from "commander";
|
|
||||||
import { withConfigOptions, type WithConfigOptions } from "cli/utils/options";
|
|
||||||
import * as utils from "./utils";
|
|
||||||
import { $console } from "core/utils";
|
|
||||||
import { Project } from "./lib/Project";
|
|
||||||
|
|
||||||
export const dev: CliCommand = (program) =>
|
|
||||||
withConfigOptions(program.command("dev"))
|
|
||||||
.description("dev server")
|
|
||||||
.addOption(new Option("--build", "build the project"))
|
|
||||||
.action(action);
|
|
||||||
|
|
||||||
async function action(options: WithConfigOptions<{ build?: boolean }>) {
|
|
||||||
console.log("options", options);
|
|
||||||
const project = new Project({
|
|
||||||
templatePath: utils.TEMPLATE_PATH,
|
|
||||||
});
|
|
||||||
|
|
||||||
await project.init();
|
|
||||||
|
|
||||||
if (options.build) {
|
|
||||||
await project.build();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
await project.listen();
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { RelativeFS } from "./RelativeFS";
|
|
||||||
//import { $console } from "bknd/utils";
|
|
||||||
import { type ViteDevServer, createServer, type UserConfig, createBuilder } from "vite";
|
|
||||||
import { readdir, copyFile, stat } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
||||||
import { getRelativeDistPath } from "cli/utils/sys";
|
|
||||||
import { injectMain } from "./vite/inject-main";
|
|
||||||
import { devFsVitePlugin } from "adapter/cloudflare";
|
|
||||||
|
|
||||||
export type ProjectOptions = {
|
|
||||||
userPath?: string;
|
|
||||||
templatePath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// @todo: add multiple public dirs
|
|
||||||
// @todo: add npm install (first time)
|
|
||||||
// @todo: add package.json
|
|
||||||
export class Project {
|
|
||||||
public userFs: RelativeFS;
|
|
||||||
public templateFs: RelativeFS;
|
|
||||||
private _server?: ViteDevServer;
|
|
||||||
|
|
||||||
constructor(public options: ProjectOptions) {
|
|
||||||
this.userFs = new RelativeFS(options.userPath ?? process.cwd());
|
|
||||||
this.templateFs = new RelativeFS(options.templatePath ?? "src/cli/commands/dev/template");
|
|
||||||
}
|
|
||||||
|
|
||||||
get server() {
|
|
||||||
if (!this._server) {
|
|
||||||
throw new Error("Server not initialized");
|
|
||||||
}
|
|
||||||
return this._server!;
|
|
||||||
}
|
|
||||||
|
|
||||||
async init() {
|
|
||||||
const source = this.templateFs.root;
|
|
||||||
const destination = this.userFs.root;
|
|
||||||
|
|
||||||
// recursively copy all files and directories from source to dest
|
|
||||||
const copyRecursive = async (src: string, dst: string) => {
|
|
||||||
const entries = await readdir(src, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const srcPath = path.join(src, entry.name);
|
|
||||||
const dstPath = path.join(dst, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
await this.userFs.makeDir(path.relative(destination, dstPath));
|
|
||||||
await copyRecursive(srcPath, dstPath);
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
const exists = await stat(dstPath)
|
|
||||||
.then((s) => s.isFile())
|
|
||||||
.catch(() => null);
|
|
||||||
|
|
||||||
// only copy everything from ".bknd" with override
|
|
||||||
if (!exists || (dstPath.includes(".bknd") && !dstPath.includes("bknd-types.d.ts"))) {
|
|
||||||
await copyFile(srcPath, dstPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await copyRecursive(source, destination);
|
|
||||||
this._server = await createServer(this.getViteConfig());
|
|
||||||
}
|
|
||||||
|
|
||||||
private getViteConfig(): UserConfig {
|
|
||||||
return {
|
|
||||||
clearScreen: false,
|
|
||||||
publicDir: getRelativeDistPath() + "/static",
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
devFsVitePlugin({ configFile: ".bknd/bknd.config.ts" }) as any,
|
|
||||||
cloudflare({
|
|
||||||
configPath: this.userFs.path(".bknd/wrangler.json"),
|
|
||||||
persistState: {
|
|
||||||
path: this.userFs.path(".bknd/state"),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
injectMain({
|
|
||||||
appPath: "./src/App.tsx",
|
|
||||||
rootId: "root",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
build: {
|
|
||||||
minify: true,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
dedupe: ["react", "react-dom"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async build() {
|
|
||||||
const builder = await createBuilder(this.getViteConfig());
|
|
||||||
await builder.buildApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
async listen() {
|
|
||||||
await this.server.listen();
|
|
||||||
this.server.printUrls();
|
|
||||||
this.server.bindCLIShortcuts({ print: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import {
|
|
||||||
mkdir,
|
|
||||||
stat,
|
|
||||||
readFile as nodeReadFile,
|
|
||||||
writeFile as nodeWriteFile,
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import { getRootPath } from "cli/utils/sys";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
export class RelativeFS {
|
|
||||||
public root: string;
|
|
||||||
|
|
||||||
constructor(p: string) {
|
|
||||||
this.root = path.resolve(getRootPath(), p);
|
|
||||||
}
|
|
||||||
|
|
||||||
path(p: string) {
|
|
||||||
return path.join(this.root, p);
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasFile(path: string) {
|
|
||||||
try {
|
|
||||||
const s = await stat(this.path(path));
|
|
||||||
return s.isFile();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async hasDir(path: string) {
|
|
||||||
try {
|
|
||||||
const s = await stat(this.path(path));
|
|
||||||
return s.isDirectory();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async readFile(p: string) {
|
|
||||||
return await nodeReadFile(this.path(p), "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeFile(p: string, content: string) {
|
|
||||||
return await nodeWriteFile(this.path(p), content);
|
|
||||||
}
|
|
||||||
|
|
||||||
async makeDir(p: string) {
|
|
||||||
return await mkdir(this.path(p), { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { PluginOption } from "vite";
|
|
||||||
|
|
||||||
export function injectMain(options?: { appPath?: string; rootId?: string }): PluginOption {
|
|
||||||
const appPath = options?.appPath ?? "/App.tsx";
|
|
||||||
const rootId = options?.rootId ?? "root";
|
|
||||||
const publicId = "/@virtual/react-main.js";
|
|
||||||
const internalId = "\0virtual-react-main.js";
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "bknd-virtual-react-entry",
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
return {
|
|
||||||
html,
|
|
||||||
tags: [
|
|
||||||
{
|
|
||||||
tag: "script",
|
|
||||||
injectTo: "body",
|
|
||||||
attrs: {
|
|
||||||
type: "module",
|
|
||||||
src: publicId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
resolveId(id) {
|
|
||||||
if (id === publicId) {
|
|
||||||
return internalId;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
load(id) {
|
|
||||||
if (id === internalId) {
|
|
||||||
return `
|
|
||||||
import React from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
|
||||||
import App from "${appPath}";
|
|
||||||
import { ClientProvider } from "bknd/client";
|
|
||||||
|
|
||||||
const container = document.getElementById("${rootId}");
|
|
||||||
if (!container) {
|
|
||||||
throw new Error("Cannot find #${rootId} element");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(container);
|
|
||||||
root.render(
|
|
||||||
React.createElement(
|
|
||||||
React.StrictMode,
|
|
||||||
null,
|
|
||||||
React.createElement(ClientProvider, null, React.createElement(App, null))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import type { DB } from "bknd";
|
|
||||||
import type { Insertable, Selectable, Updateable } from "kysely";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
type BkndEntity<T extends keyof DB> = Selectable<DB[T]>;
|
|
||||||
type BkndEntityCreate<T extends keyof DB> = Insertable<DB[T]>;
|
|
||||||
type BkndEntityUpdate<T extends keyof DB> = Updateable<DB[T]>;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true,
|
|
||||||
"types": ["vite/client"]
|
|
||||||
},
|
|
||||||
"include": ["bknd-types.d.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { serve } from "bknd/adapter/cloudflare";
|
|
||||||
import config from "./bknd.config";
|
|
||||||
|
|
||||||
export default serve(config);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "bknd-dev",
|
|
||||||
"main": "./worker.ts",
|
|
||||||
"compatibility_date": "2025-10-08",
|
|
||||||
"compatibility_flags": ["nodejs_compat"],
|
|
||||||
"observability": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"assets": {
|
|
||||||
"binding": "ASSETS",
|
|
||||||
"directory": "../dist/client",
|
|
||||||
"not_found_handling": "single-page-application",
|
|
||||||
"run_worker_first": ["!/", "/admin*", "/api*", "!/assets/*"]
|
|
||||||
},
|
|
||||||
"vars": {
|
|
||||||
"ENVIRONMENT": "development"
|
|
||||||
},
|
|
||||||
"d1_databases": [
|
|
||||||
{
|
|
||||||
"binding": "DB"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"r2_buckets": [
|
|
||||||
{
|
|
||||||
"binding": "BUCKET"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>bknd + Vite + Cloudflare + React + TS</title>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import "./styles.css";
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <div>Hello World</div>;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import type { ServerEnv } from "bknd";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add custom routes to the API here. Base path is `/api`.
|
|
||||||
*/
|
|
||||||
export default new Hono<ServerEnv>().get("/", (c) => {
|
|
||||||
// const app = c.var.app;
|
|
||||||
// const api = app.getApi();
|
|
||||||
return c.json({ message: "Hello, world!" });
|
|
||||||
});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { AppEvents, DatabaseEvents, type EventManager } from "bknd";
|
|
||||||
|
|
||||||
export default function (emgr: EventManager) {
|
|
||||||
// emgr.onEvent(AppEvents.AppRequest, async (event) => {
|
|
||||||
// console.log("Request received", event.params.request.url);
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { em, entity, text, boolean, number, datetime, json, jsonSchema, enumm } from "bknd";
|
|
||||||
|
|
||||||
export default em({
|
|
||||||
// todos: entity("todos", {
|
|
||||||
// title: text(),
|
|
||||||
// done: boolean(),
|
|
||||||
// }),
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.bknd/tsconfig.json",
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import {
|
|
||||||
mkdir,
|
|
||||||
stat,
|
|
||||||
readFile as nodeReadFile,
|
|
||||||
writeFile as nodeWriteFile,
|
|
||||||
} from "node:fs/promises";
|
|
||||||
import { getRootPath } from "cli/utils/sys";
|
|
||||||
|
|
||||||
export const TEMPLATE_PATH = "src/cli/commands/dev/template";
|
|
||||||
export const currentDir = process.cwd();
|
|
||||||
|
|
||||||
export const fs = (dir: string) => {
|
|
||||||
const PATH = path.resolve(getRootPath(), dir);
|
|
||||||
return {
|
|
||||||
PATH,
|
|
||||||
path: (_path: string) => path.join(PATH, _path),
|
|
||||||
hasFile: async (file: string) => {
|
|
||||||
try {
|
|
||||||
const s = await stat(path.join(PATH, file));
|
|
||||||
return s.isFile();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
hasDir: async (_dir: string) => {
|
|
||||||
try {
|
|
||||||
const s = await stat(path.join(PATH, _dir));
|
|
||||||
return s.isDirectory();
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
readFile: (file: string) => nodeReadFile(path.join(PATH, file), "utf-8"),
|
|
||||||
readJsonFile: async (file: string) =>
|
|
||||||
JSON.parse(await nodeReadFile(path.join(PATH, file), "utf-8")),
|
|
||||||
writeFile: (file: string, content: string) => nodeWriteFile(path.join(PATH, file), content),
|
|
||||||
makeDir: (_newDir: string) => mkdir(path.join(PATH, _newDir)),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -9,4 +9,3 @@ export { types } from "./types";
|
|||||||
export { mcp } from "./mcp/mcp";
|
export { mcp } from "./mcp/mcp";
|
||||||
export { sync } from "./sync";
|
export { sync } from "./sync";
|
||||||
export { secrets } from "./secrets";
|
export { secrets } from "./secrets";
|
||||||
export { dev } from "./dev/dev";
|
|
||||||
|
|||||||
@@ -67,10 +67,7 @@ export async function startServer(
|
|||||||
$console.info("Server listening on", url);
|
$console.info("Server listening on", url);
|
||||||
|
|
||||||
if (options.open) {
|
if (options.open) {
|
||||||
const p = await open(url, { wait: false });
|
await open(url);
|
||||||
p.on("error", () => {
|
|
||||||
$console.warn("Couldn't open url in browser");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import color from "picocolors";
|
import color from "picocolors";
|
||||||
import * as commands from "./commands";
|
import * as commands from "./commands";
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, it, expect } from "bun:test";
|
|
||||||
import { plunkEmail } from "./plunk";
|
|
||||||
|
|
||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
|
||||||
|
|
||||||
describe.skipIf(ALL_TESTS)("plunk", () => {
|
|
||||||
it("should throw on failed", async () => {
|
|
||||||
const driver = plunkEmail({ apiKey: "invalid" });
|
|
||||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should send an email", async () => {
|
|
||||||
const driver = plunkEmail({
|
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
|
||||||
from: undefined, // Default to what Plunk sets
|
|
||||||
});
|
|
||||||
const response = await driver.send(
|
|
||||||
"help@bknd.io",
|
|
||||||
"Test Email from Plunk",
|
|
||||||
"This is a test email",
|
|
||||||
);
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.success).toBe(true);
|
|
||||||
expect(response.emails).toBeDefined();
|
|
||||||
expect(response.timestamp).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should send HTML email", async () => {
|
|
||||||
const driver = plunkEmail({
|
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
|
||||||
from: undefined,
|
|
||||||
});
|
|
||||||
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
|
||||||
const response = await driver.send(
|
|
||||||
"help@bknd.io",
|
|
||||||
"HTML Test",
|
|
||||||
htmlBody,
|
|
||||||
);
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should send with text and html", async () => {
|
|
||||||
const driver = plunkEmail({
|
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
|
||||||
from: undefined,
|
|
||||||
});
|
|
||||||
const response = await driver.send("test@example.com", "Test Email", {
|
|
||||||
text: "help@bknd.io",
|
|
||||||
html: "<p>This is HTML</p>",
|
|
||||||
});
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import type { IEmailDriver } from "./index";
|
|
||||||
|
|
||||||
export type PlunkEmailOptions = {
|
|
||||||
apiKey: string;
|
|
||||||
host?: string;
|
|
||||||
from?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlunkEmailSendOptions = {
|
|
||||||
subscribed?: boolean;
|
|
||||||
name?: string;
|
|
||||||
from?: string;
|
|
||||||
reply?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlunkEmailResponse = {
|
|
||||||
success: boolean;
|
|
||||||
emails: Array<{
|
|
||||||
contact: {
|
|
||||||
id: string;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
email: string;
|
|
||||||
}>;
|
|
||||||
timestamp: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const plunkEmail = (
|
|
||||||
config: PlunkEmailOptions,
|
|
||||||
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
|
||||||
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
|
||||||
const from = config.from;
|
|
||||||
|
|
||||||
return {
|
|
||||||
send: async (
|
|
||||||
to: string,
|
|
||||||
subject: string,
|
|
||||||
body: string | { text: string; html: string },
|
|
||||||
options?: PlunkEmailSendOptions,
|
|
||||||
) => {
|
|
||||||
const payload: any = {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
subject,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof body === "string") {
|
|
||||||
payload.body = body;
|
|
||||||
} else {
|
|
||||||
payload.body = body.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(host, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ ...payload, ...options }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Plunk API error: ${await res.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (await res.json()) as PlunkEmailResponse;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -5,4 +5,3 @@ export type { IEmailDriver } from "./email";
|
|||||||
export { resendEmail } from "./email/resend";
|
export { resendEmail } from "./email/resend";
|
||||||
export { sesEmail } from "./email/ses";
|
export { sesEmail } from "./email/ses";
|
||||||
export { mailchannelsEmail } from "./email/mailchannels";
|
export { mailchannelsEmail } from "./email/mailchannels";
|
||||||
export { plunkEmail } from "./email/plunk";
|
|
||||||
|
|||||||
+2
-1
@@ -73,8 +73,9 @@ export const env = <
|
|||||||
onValid?: (valid: R) => void;
|
onValid?: (valid: R) => void;
|
||||||
},
|
},
|
||||||
): R extends undefined ? Fallback : R => {
|
): R extends undefined ? Fallback : R => {
|
||||||
|
const source = opts?.source ?? (typeof process !== "undefined" ? process?.env : {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = opts?.source ?? process.env;
|
|
||||||
const c = envs[key];
|
const c = envs[key];
|
||||||
const g = source[c.key];
|
const g = source[c.key];
|
||||||
const v = c.validate(g) as any;
|
const v = c.validate(g) as any;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { MaybePromise } from "bknd";
|
|
||||||
import type { Event } from "./Event";
|
import type { Event } from "./Event";
|
||||||
import type { EventClass } from "./EventManager";
|
import type { EventClass } from "./EventManager";
|
||||||
|
|
||||||
@@ -8,7 +7,7 @@ export type ListenerMode = (typeof ListenerModes)[number];
|
|||||||
export type ListenerHandler<E extends Event<any, any>> = (
|
export type ListenerHandler<E extends Event<any, any>> = (
|
||||||
event: E,
|
event: E,
|
||||||
slug: string,
|
slug: string,
|
||||||
) => E extends Event<any, infer R> ? MaybePromise<R | void> : never;
|
) => E extends Event<any, infer R> ? R | Promise<R | void> : never;
|
||||||
|
|
||||||
export class EventListener<E extends Event = Event> {
|
export class EventListener<E extends Event = Event> {
|
||||||
mode: ListenerMode = "async";
|
mode: ListenerMode = "async";
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export function getFlashMessage(
|
|||||||
): { type: FlashMessageType; message: string } | undefined {
|
): { type: FlashMessageType; message: string } | undefined {
|
||||||
const flash = getCookieValue(flash_key);
|
const flash = getCookieValue(flash_key);
|
||||||
if (flash && clear) {
|
if (flash && clear) {
|
||||||
// biome-ignore lint/suspicious/noDocumentCookie: .
|
|
||||||
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
document.cookie = `${flash_key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
||||||
}
|
}
|
||||||
return flash ? JSON.parse(flash) : undefined;
|
return flash ? JSON.parse(flash) : undefined;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
|||||||
|
|
||||||
export function omitKeys<T extends object, K extends keyof T>(
|
export function omitKeys<T extends object, K extends keyof T>(
|
||||||
obj: T,
|
obj: T,
|
||||||
keys_: readonly K[] | K[] | string[],
|
keys_: readonly K[],
|
||||||
): Omit<T, Extract<K, keyof T>> {
|
): Omit<T, Extract<K, keyof T>> {
|
||||||
const keys = new Set(keys_ as readonly K[]);
|
const keys = new Set(keys_);
|
||||||
const result = {} as Omit<T, Extract<K, keyof T>>;
|
const result = {} as Omit<T, Extract<K, keyof T>>;
|
||||||
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
for (const [key, value] of Object.entries(obj) as [keyof T, T[keyof T]][]) {
|
||||||
if (!keys.has(key as K)) {
|
if (!keys.has(key as K)) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { MaybePromise } from "core/types";
|
|
||||||
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
import { getRuntimeKey as honoGetRuntimeKey } from "hono/adapter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,37 +77,3 @@ export function threw(fn: () => any, instance?: new (...args: any[]) => Error) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function threwAsync(fn: Promise<any>, instance?: new (...args: any[]) => Error) {
|
|
||||||
try {
|
|
||||||
await fn;
|
|
||||||
return false;
|
|
||||||
} catch (e) {
|
|
||||||
if (instance) {
|
|
||||||
if (e instanceof instance) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// if instance given but not what expected, throw
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function $waitUntil(
|
|
||||||
message: string,
|
|
||||||
condition: () => MaybePromise<boolean>,
|
|
||||||
delay = 100,
|
|
||||||
maxAttempts = 10,
|
|
||||||
) {
|
|
||||||
let attempts = 0;
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
if (await condition()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
attempts++;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`$waitUntil: "${message}" failed after ${maxAttempts} attempts`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -96,9 +96,6 @@ export class DataController extends Controller {
|
|||||||
// read entity schema
|
// read entity schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schema.json",
|
"/schema.json",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "data" }),
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead, {
|
permission(DataPermissions.entityRead, {
|
||||||
context: (c) => ({ entity: c.req.param("entity") }),
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
}),
|
}),
|
||||||
@@ -127,9 +124,6 @@ export class DataController extends Controller {
|
|||||||
// read schema
|
// read schema
|
||||||
hono.get(
|
hono.get(
|
||||||
"/schemas/:entity/:context?",
|
"/schemas/:entity/:context?",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "data" }),
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead, {
|
permission(DataPermissions.entityRead, {
|
||||||
context: (c) => ({ entity: c.req.param("entity") }),
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
}),
|
}),
|
||||||
@@ -167,7 +161,7 @@ export class DataController extends Controller {
|
|||||||
hono.get(
|
hono.get(
|
||||||
"/types",
|
"/types",
|
||||||
permission(SystemPermissions.schemaRead, {
|
permission(SystemPermissions.schemaRead, {
|
||||||
context: (_c) => ({ module: "data" }),
|
context: (c) => ({ module: "data" }),
|
||||||
}),
|
}),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Retrieve data typescript definitions",
|
summary: "Retrieve data typescript definitions",
|
||||||
@@ -188,9 +182,6 @@ export class DataController extends Controller {
|
|||||||
*/
|
*/
|
||||||
hono.get(
|
hono.get(
|
||||||
"/info/:entity",
|
"/info/:entity",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "data" }),
|
|
||||||
}),
|
|
||||||
permission(DataPermissions.entityRead, {
|
permission(DataPermissions.entityRead, {
|
||||||
context: (c) => ({ entity: c.req.param("entity") }),
|
context: (c) => ({ entity: c.req.param("entity") }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import {
|
|||||||
type CompiledQuery,
|
type CompiledQuery,
|
||||||
type DatabaseIntrospector,
|
type DatabaseIntrospector,
|
||||||
type Dialect,
|
type Dialect,
|
||||||
|
type Expression,
|
||||||
type Kysely,
|
type Kysely,
|
||||||
type KyselyPlugin,
|
type KyselyPlugin,
|
||||||
type OnModifyForeignAction,
|
type OnModifyForeignAction,
|
||||||
type QueryResult,
|
type QueryResult,
|
||||||
|
type RawBuilder,
|
||||||
type SelectQueryBuilder,
|
type SelectQueryBuilder,
|
||||||
type SelectQueryNode,
|
type SelectQueryNode,
|
||||||
|
type Simplify,
|
||||||
sql,
|
sql,
|
||||||
} from "kysely";
|
} from "kysely";
|
||||||
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
import type { jsonArrayFrom, jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||||
|
|||||||
@@ -14,31 +14,27 @@ export function connectionTestSuite(
|
|||||||
{
|
{
|
||||||
makeConnection,
|
makeConnection,
|
||||||
rawDialectDetails,
|
rawDialectDetails,
|
||||||
disableConsoleLog: _disableConsoleLog = true,
|
|
||||||
}: {
|
}: {
|
||||||
makeConnection: () => MaybePromise<{
|
makeConnection: () => MaybePromise<{
|
||||||
connection: Connection;
|
connection: Connection;
|
||||||
dispose: () => MaybePromise<void>;
|
dispose: () => MaybePromise<void>;
|
||||||
}>;
|
}>;
|
||||||
rawDialectDetails: string[];
|
rawDialectDetails: string[];
|
||||||
disableConsoleLog?: boolean;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
const { test, expect, describe, beforeEach, afterEach, afterAll, beforeAll } = testRunner;
|
||||||
if (_disableConsoleLog) {
|
beforeAll(() => disableConsoleLog());
|
||||||
beforeAll(() => disableConsoleLog());
|
afterAll(() => enableConsoleLog());
|
||||||
afterAll(() => enableConsoleLog());
|
|
||||||
}
|
|
||||||
|
|
||||||
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
describe("base", () => {
|
||||||
beforeEach(async () => {
|
let ctx: Awaited<ReturnType<typeof makeConnection>>;
|
||||||
ctx = await makeConnection();
|
beforeEach(async () => {
|
||||||
});
|
ctx = await makeConnection();
|
||||||
afterEach(async () => {
|
});
|
||||||
await ctx.dispose();
|
afterEach(async () => {
|
||||||
});
|
await ctx.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
describe("base", async () => {
|
|
||||||
test("pings", async () => {
|
test("pings", async () => {
|
||||||
const res = await ctx.connection.ping();
|
const res = await ctx.connection.ping();
|
||||||
expect(res).toBe(true);
|
expect(res).toBe(true);
|
||||||
@@ -102,54 +98,52 @@ export function connectionTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("schema", async () => {
|
describe("schema", async () => {
|
||||||
const makeSchema = async () => {
|
const { connection, dispose } = await makeConnection();
|
||||||
const fields = [
|
afterAll(async () => {
|
||||||
{
|
await dispose();
|
||||||
type: "integer",
|
});
|
||||||
name: "id",
|
|
||||||
primary: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
name: "text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "json",
|
|
||||||
name: "json",
|
|
||||||
},
|
|
||||||
] as const satisfies FieldSpec[];
|
|
||||||
|
|
||||||
let b = ctx.connection.kysely.schema.createTable("test");
|
const fields = [
|
||||||
for (const field of fields) {
|
{
|
||||||
// @ts-expect-error
|
type: "integer",
|
||||||
b = b.addColumn(...ctx.connection.getFieldSchema(field));
|
name: "id",
|
||||||
}
|
primary: true,
|
||||||
await b.execute();
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
name: "text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "json",
|
||||||
|
name: "json",
|
||||||
|
},
|
||||||
|
] as const satisfies FieldSpec[];
|
||||||
|
|
||||||
// add index
|
let b = connection.kysely.schema.createTable("test");
|
||||||
await ctx.connection.kysely.schema
|
for (const field of fields) {
|
||||||
.createIndex("test_index")
|
// @ts-expect-error
|
||||||
.on("test")
|
b = b.addColumn(...connection.getFieldSchema(field));
|
||||||
.columns(["id"])
|
}
|
||||||
.execute();
|
await b.execute();
|
||||||
};
|
|
||||||
|
// add index
|
||||||
|
await connection.kysely.schema.createIndex("test_index").on("test").columns(["id"]).execute();
|
||||||
|
|
||||||
test("executes query", async () => {
|
test("executes query", async () => {
|
||||||
await makeSchema();
|
await connection.kysely
|
||||||
await ctx.connection.kysely
|
|
||||||
.insertInto("test")
|
.insertInto("test")
|
||||||
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
|
.values({ id: 1, text: "test", json: JSON.stringify({ a: 1 }) })
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
const expected = { id: 1, text: "test", json: { a: 1 } };
|
const expected = { id: 1, text: "test", json: { a: 1 } };
|
||||||
|
|
||||||
const qb = ctx.connection.kysely.selectFrom("test").selectAll();
|
const qb = connection.kysely.selectFrom("test").selectAll();
|
||||||
const res = await ctx.connection.executeQuery(qb);
|
const res = await connection.executeQuery(qb);
|
||||||
expect(res.rows).toEqual([expected]);
|
expect(res.rows).toEqual([expected]);
|
||||||
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
|
expect(rawDialectDetails.every((detail) => getPath(res, detail) !== undefined)).toBe(true);
|
||||||
|
|
||||||
{
|
{
|
||||||
const res = await ctx.connection.executeQueries(qb, qb);
|
const res = await connection.executeQueries(qb, qb);
|
||||||
expect(res.length).toBe(2);
|
expect(res.length).toBe(2);
|
||||||
res.map((r) => {
|
res.map((r) => {
|
||||||
expect(r.rows).toEqual([expected]);
|
expect(r.rows).toEqual([expected]);
|
||||||
@@ -161,21 +155,15 @@ export function connectionTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("introspects", async () => {
|
test("introspects", async () => {
|
||||||
await makeSchema();
|
const tables = await connection.getIntrospector().getTables({
|
||||||
const tables = await ctx.connection.getIntrospector().getTables({
|
|
||||||
withInternalKyselyTables: false,
|
withInternalKyselyTables: false,
|
||||||
});
|
});
|
||||||
const clean = tables.map((t) => ({
|
const clean = tables.map((t) => ({
|
||||||
...t,
|
...t,
|
||||||
columns: t.columns
|
columns: t.columns.map((c) => ({
|
||||||
.map((c) => ({
|
...c,
|
||||||
...c,
|
dataType: undefined,
|
||||||
// ignore data type
|
})),
|
||||||
dataType: undefined,
|
|
||||||
// ignore default value if "id"
|
|
||||||
hasDefaultValue: c.name !== "id" ? c.hasDefaultValue : undefined,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
expect(clean).toEqual([
|
expect(clean).toEqual([
|
||||||
@@ -188,16 +176,7 @@ export function connectionTestSuite(
|
|||||||
dataType: undefined,
|
dataType: undefined,
|
||||||
isNullable: false,
|
isNullable: false,
|
||||||
isAutoIncrementing: true,
|
isAutoIncrementing: true,
|
||||||
hasDefaultValue: undefined,
|
|
||||||
comment: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "json",
|
|
||||||
dataType: undefined,
|
|
||||||
isNullable: true,
|
|
||||||
isAutoIncrementing: false,
|
|
||||||
hasDefaultValue: false,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "text",
|
name: "text",
|
||||||
@@ -205,26 +184,32 @@ export function connectionTestSuite(
|
|||||||
isNullable: true,
|
isNullable: true,
|
||||||
isAutoIncrementing: false,
|
isAutoIncrementing: false,
|
||||||
hasDefaultValue: false,
|
hasDefaultValue: false,
|
||||||
comment: undefined,
|
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(await ctx.connection.getIntrospector().getIndices()).toEqual([
|
|
||||||
{
|
|
||||||
name: "test_index",
|
|
||||||
table: "test",
|
|
||||||
isUnique: false,
|
|
||||||
columns: [
|
|
||||||
{
|
{
|
||||||
name: "id",
|
name: "json",
|
||||||
order: 0,
|
dataType: undefined,
|
||||||
|
isNullable: true,
|
||||||
|
isAutoIncrementing: false,
|
||||||
|
hasDefaultValue: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(await connection.getIntrospector().getIndices()).toEqual([
|
||||||
|
{
|
||||||
|
name: "test_index",
|
||||||
|
table: "test",
|
||||||
|
isUnique: false,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: "id",
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("integration", async () => {
|
describe("integration", async () => {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
|
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
|
||||||
import { customIntrospector } from "../Connection";
|
|
||||||
import type { Pool } from "pg";
|
|
||||||
|
|
||||||
export type PostgresDialectConfig = Omit<KyselyPostgresDialectConfig, "pool"> & {
|
|
||||||
pool: Pool;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PgPostgresConnection extends PostgresConnection<Pool> {
|
|
||||||
override name = "pg";
|
|
||||||
|
|
||||||
constructor(config: PostgresDialectConfig) {
|
|
||||||
const kysely = new Kysely({
|
|
||||||
dialect: customIntrospector(PostgresDialect, PostgresIntrospector, {
|
|
||||||
excludeTables: [],
|
|
||||||
}).create(config),
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
|
|
||||||
super(kysely);
|
|
||||||
this.client = config.pool;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async close(): Promise<void> {
|
|
||||||
await this.client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pg(config: PostgresDialectConfig): PgPostgresConnection {
|
|
||||||
return new PgPostgresConnection(config);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { Kysely } from "kysely";
|
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
|
||||||
import { customIntrospector } from "../Connection";
|
|
||||||
import { PostgresJSDialect, type PostgresJSDialectConfig } from "kysely-postgres-js";
|
|
||||||
|
|
||||||
export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectConfig["postgres"]> {
|
|
||||||
override name = "postgres-js";
|
|
||||||
|
|
||||||
constructor(config: PostgresJSDialectConfig) {
|
|
||||||
const kysely = new Kysely({
|
|
||||||
dialect: customIntrospector(PostgresJSDialect, PostgresIntrospector, {
|
|
||||||
excludeTables: [],
|
|
||||||
}).create(config),
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
|
|
||||||
super(kysely);
|
|
||||||
this.client = config.postgres;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async close(): Promise<void> {
|
|
||||||
await this.client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function postgresJs(
|
|
||||||
config: PostgresJSDialectConfig,
|
|
||||||
): PostgresJsConnection {
|
|
||||||
return new PostgresJsConnection(config);
|
|
||||||
}
|
|
||||||
@@ -13,43 +13,31 @@ import { customIntrospector } from "../Connection";
|
|||||||
import { SqliteIntrospector } from "./SqliteIntrospector";
|
import { SqliteIntrospector } from "./SqliteIntrospector";
|
||||||
import type { Field } from "data/fields/Field";
|
import type { Field } from "data/fields/Field";
|
||||||
|
|
||||||
|
// @todo: add pragmas
|
||||||
export type SqliteConnectionConfig<
|
export type SqliteConnectionConfig<
|
||||||
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
|
CustomDialect extends Constructor<Dialect> = Constructor<Dialect>,
|
||||||
> = {
|
> = {
|
||||||
excludeTables?: string[];
|
excludeTables?: string[];
|
||||||
|
dialect: CustomDialect;
|
||||||
|
dialectArgs?: ConstructorParameters<CustomDialect>;
|
||||||
additionalPlugins?: KyselyPlugin[];
|
additionalPlugins?: KyselyPlugin[];
|
||||||
customFn?: Partial<DbFunctions>;
|
customFn?: Partial<DbFunctions>;
|
||||||
} & (
|
};
|
||||||
| {
|
|
||||||
dialect: CustomDialect;
|
|
||||||
dialectArgs?: ConstructorParameters<CustomDialect>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kysely: Kysely<any>;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
|
export abstract class SqliteConnection<Client = unknown> extends Connection<Client> {
|
||||||
override name = "sqlite";
|
override name = "sqlite";
|
||||||
|
|
||||||
constructor(config: SqliteConnectionConfig) {
|
constructor(config: SqliteConnectionConfig) {
|
||||||
const { excludeTables, additionalPlugins } = config;
|
const { excludeTables, dialect, dialectArgs = [], additionalPlugins } = config;
|
||||||
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
|
const plugins = [new ParseJSONResultsPlugin(), ...(additionalPlugins ?? [])];
|
||||||
|
|
||||||
let kysely: Kysely<any>;
|
const kysely = new Kysely({
|
||||||
if ("dialect" in config) {
|
dialect: customIntrospector(dialect, SqliteIntrospector, {
|
||||||
kysely = new Kysely({
|
excludeTables,
|
||||||
dialect: customIntrospector(config.dialect, SqliteIntrospector, {
|
|
||||||
excludeTables,
|
|
||||||
plugins,
|
|
||||||
}).create(...(config.dialectArgs ?? [])),
|
|
||||||
plugins,
|
plugins,
|
||||||
});
|
}).create(...dialectArgs),
|
||||||
} else if ("kysely" in config) {
|
plugins,
|
||||||
kysely = config.kysely;
|
});
|
||||||
} else {
|
|
||||||
throw new Error("Either dialect or kysely must be provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
super(
|
super(
|
||||||
kysely,
|
kysely,
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export class SqliteIntrospector extends BaseIntrospector {
|
|||||||
dataType: col.type,
|
dataType: col.type,
|
||||||
isNullable: !col.notnull,
|
isNullable: !col.notnull,
|
||||||
isAutoIncrementing: col.name === autoIncrementCol,
|
isAutoIncrementing: col.name === autoIncrementCol,
|
||||||
hasDefaultValue: col.name === autoIncrementCol ? true : col.dflt_value != null,
|
hasDefaultValue: col.dflt_value != null,
|
||||||
comment: undefined,
|
comment: undefined,
|
||||||
};
|
};
|
||||||
}) ?? [],
|
}) ?? [],
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import { describe } from "bun:test";
|
|
||||||
import { SQLocalConnection } from "./SQLocalConnection";
|
|
||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
|
||||||
import { SQLocalKysely } from "sqlocal/kysely";
|
|
||||||
|
|
||||||
describe("SQLocalConnection", () => {
|
|
||||||
connectionTestSuite(bunTestRunner, {
|
|
||||||
makeConnection: () => ({
|
|
||||||
connection: new SQLocalConnection(new SQLocalKysely({ databasePath: ":memory:" })),
|
|
||||||
dispose: async () => {},
|
|
||||||
}),
|
|
||||||
rawDialectDetails: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Kysely, ParseJSONResultsPlugin } from "kysely";
|
|
||||||
import { SqliteConnection } from "../SqliteConnection";
|
|
||||||
import { SqliteIntrospector } from "../SqliteIntrospector";
|
|
||||||
import type { DB } from "bknd";
|
|
||||||
import type { SQLocalKysely } from "sqlocal/kysely";
|
|
||||||
|
|
||||||
const plugins = [new ParseJSONResultsPlugin()];
|
|
||||||
|
|
||||||
export class SQLocalConnection extends SqliteConnection<SQLocalKysely> {
|
|
||||||
private connected: boolean = false;
|
|
||||||
|
|
||||||
constructor(client: SQLocalKysely) {
|
|
||||||
// @ts-expect-error - config is protected
|
|
||||||
client.config.onConnect = () => {
|
|
||||||
// we need to listen for the connection, it will be awaited in init()
|
|
||||||
this.connected = true;
|
|
||||||
};
|
|
||||||
super({
|
|
||||||
kysely: new Kysely<any>({
|
|
||||||
dialect: {
|
|
||||||
...client.dialect,
|
|
||||||
createIntrospector: (db: Kysely<DB>) => {
|
|
||||||
return new SqliteIntrospector(db as any, {
|
|
||||||
plugins,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins,
|
|
||||||
}) as any,
|
|
||||||
});
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
override async init() {
|
|
||||||
if (this.initialized) return;
|
|
||||||
let tries = 0;
|
|
||||||
while (!this.connected && tries < 100) {
|
|
||||||
tries++;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
}
|
|
||||||
if (!this.connected) {
|
|
||||||
throw new Error("Failed to connect to SQLite database");
|
|
||||||
}
|
|
||||||
this.initialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sqlocal(instance: InstanceType<typeof SQLocalKysely>): SQLocalConnection {
|
|
||||||
return new SQLocalConnection(instance);
|
|
||||||
}
|
|
||||||
@@ -103,7 +103,6 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
validated.with = options.with;
|
validated.with = options.with;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add explicit joins. Implicit joins are added in `where` builder
|
|
||||||
if (options.join && options.join.length > 0) {
|
if (options.join && options.join.length > 0) {
|
||||||
for (const entry of options.join) {
|
for (const entry of options.join) {
|
||||||
const related = this.em.relationOf(entity.name, entry);
|
const related = this.em.relationOf(entity.name, entry);
|
||||||
@@ -128,28 +127,12 @@ export class Repository<TBD extends object = DefaultDB, TB extends keyof TBD = a
|
|||||||
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
const invalid = WhereBuilder.getPropertyNames(options.where).filter((field) => {
|
||||||
if (field.includes(".")) {
|
if (field.includes(".")) {
|
||||||
const [alias, prop] = field.split(".") as [string, string];
|
const [alias, prop] = field.split(".") as [string, string];
|
||||||
// check aliases first (added joins)
|
if (!aliases.includes(alias)) {
|
||||||
if (aliases.includes(alias)) {
|
|
||||||
this.checkIndex(alias, prop, "where");
|
|
||||||
return !this.em.entity(alias).getField(prop);
|
|
||||||
}
|
|
||||||
// check if alias (entity) exists
|
|
||||||
if (!this.em.hasEntity(alias)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// check related fields for auto join
|
|
||||||
const related = this.em.relationOf(entity.name, alias);
|
|
||||||
if (related) {
|
|
||||||
const other = related.other(entity);
|
|
||||||
if (other.entity.getField(prop)) {
|
|
||||||
// if related field is found, add join to validated options
|
|
||||||
validated.join?.push(alias);
|
|
||||||
this.checkIndex(alias, prop, "where");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
this.checkIndex(alias, prop, "where");
|
||||||
|
return !this.em.entity(alias).getField(prop);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.checkIndex(entity.name, field, "where");
|
this.checkIndex(entity.name, field, "where");
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { KyselyJsonFrom } from "data/relations/EntityRelation";
|
|||||||
import type { RepoQuery } from "data/server/query";
|
import type { RepoQuery } from "data/server/query";
|
||||||
import { InvalidSearchParamsException } from "data/errors";
|
import { InvalidSearchParamsException } from "data/errors";
|
||||||
import type { Entity, EntityManager, RepositoryQB } from "data/entities";
|
import type { Entity, EntityManager, RepositoryQB } from "data/entities";
|
||||||
import { $console } from "bknd/utils";
|
|
||||||
|
|
||||||
export class WithBuilder {
|
export class WithBuilder {
|
||||||
static addClause(
|
static addClause(
|
||||||
@@ -14,7 +13,7 @@ export class WithBuilder {
|
|||||||
withs: RepoQuery["with"],
|
withs: RepoQuery["with"],
|
||||||
) {
|
) {
|
||||||
if (!withs || !isObject(withs)) {
|
if (!withs || !isObject(withs)) {
|
||||||
$console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
console.warn(`'withs' undefined or invalid, given: ${JSON.stringify(withs)}`);
|
||||||
return qb;
|
return qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +37,9 @@ export class WithBuilder {
|
|||||||
let subQuery = relation.buildWith(entity, ref)(eb);
|
let subQuery = relation.buildWith(entity, ref)(eb);
|
||||||
if (query) {
|
if (query) {
|
||||||
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
subQuery = em.repo(other.entity).addOptionsToQueryBuilder(subQuery, query as any, {
|
||||||
ignore: ["with", cardinality === 1 ? "limit" : undefined].filter(Boolean) as any,
|
ignore: ["with", "join", cardinality === 1 ? "limit" : undefined].filter(
|
||||||
|
Boolean,
|
||||||
|
) as any,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ export class WithBuilder {
|
|||||||
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
|
static validateWiths(em: EntityManager<any>, entity: string, withs: RepoQuery["with"]) {
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
if (!withs || !isObject(withs)) {
|
if (!withs || !isObject(withs)) {
|
||||||
withs && $console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
|
withs && console.warn(`'withs' invalid, given: ${JSON.stringify(withs)}`);
|
||||||
return depth;
|
return depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,7 @@ export class JsonSchemaField<
|
|||||||
|
|
||||||
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
|
constructor(name: string, config: Partial<JsonSchemaFieldConfig>) {
|
||||||
super(name, config);
|
super(name, config);
|
||||||
|
this.validator = new Validator({ ...this.getJsonSchema() });
|
||||||
// make sure to hand over clean json
|
|
||||||
const schema = this.getJsonSchema();
|
|
||||||
this.validator = new Validator(
|
|
||||||
typeof schema === "object" ? JSON.parse(JSON.stringify(schema)) : {},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getSchema() {
|
protected getSchema() {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class NumberField<Required extends true | false = false> extends Field<
|
|||||||
|
|
||||||
switch (context) {
|
switch (context) {
|
||||||
case "submit":
|
case "submit":
|
||||||
return Number.parseInt(value, 10);
|
return Number.parseInt(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function getChangeSet(
|
|||||||
const value = _value === "" ? null : _value;
|
const value = _value === "" ? null : _value;
|
||||||
|
|
||||||
// normalize to null if undefined
|
// normalize to null if undefined
|
||||||
const newValue = field.getValue(value, "submit") ?? null;
|
const newValue = field.getValue(value, "submit") || null;
|
||||||
// @todo: add typing for "action"
|
// @todo: add typing for "action"
|
||||||
if (action === "create" || newValue !== data[key]) {
|
if (action === "create" || newValue !== data[key]) {
|
||||||
acc[key] = newValue;
|
acc[key] = newValue;
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ class EntityManagerPrototype<Entities extends Record<string, Entity>> extends En
|
|||||||
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
super(Object.values(__entities), new DummyConnection(), relations, indices);
|
||||||
}
|
}
|
||||||
|
|
||||||
withConnection(connection: Connection): EntityManager<Schemas<Entities>> {
|
withConnection(connection: Connection): EntityManager<Schema<Entities>> {
|
||||||
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
|
return new EntityManager(this.entities, connection, this.relations.all, this.indices);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { test, describe, expect, beforeAll, afterAll } from "bun:test";
|
import { test, describe, expect } from "bun:test";
|
||||||
import * as q from "./query";
|
import * as q from "./query";
|
||||||
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
import { parse as $parse, type ParseOptions } from "bknd/utils";
|
||||||
import type { PrimaryFieldType } from "modules";
|
import type { PrimaryFieldType } from "modules";
|
||||||
import type { Generated } from "kysely";
|
import type { Generated } from "kysely";
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
const parse = (v: unknown, o: ParseOptions = {}) =>
|
const parse = (v: unknown, o: ParseOptions = {}) =>
|
||||||
$parse(q.repoQuery, v, {
|
$parse(q.repoQuery, v, {
|
||||||
@@ -16,9 +15,6 @@ const decode = (input: any, output: any) => {
|
|||||||
expect(parse(input)).toEqual(output);
|
expect(parse(input)).toEqual(output);
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeAll(() => disableConsoleLog());
|
|
||||||
afterAll(() => enableConsoleLog());
|
|
||||||
|
|
||||||
describe("server/query", () => {
|
describe("server/query", () => {
|
||||||
test("limit & offset", () => {
|
test("limit & offset", () => {
|
||||||
//expect(() => parse({ limit: false })).toThrow();
|
//expect(() => parse({ limit: false })).toThrow();
|
||||||
|
|||||||
@@ -132,8 +132,6 @@ export type * from "data/entities/Entity";
|
|||||||
export type { EntityManager } from "data/entities/EntityManager";
|
export type { EntityManager } from "data/entities/EntityManager";
|
||||||
export type { SchemaManager } from "data/schema/SchemaManager";
|
export type { SchemaManager } from "data/schema/SchemaManager";
|
||||||
export type * from "data/entities";
|
export type * from "data/entities";
|
||||||
|
|
||||||
// data connection
|
|
||||||
export {
|
export {
|
||||||
BaseIntrospector,
|
BaseIntrospector,
|
||||||
Connection,
|
Connection,
|
||||||
@@ -146,32 +144,9 @@ export {
|
|||||||
type ConnQuery,
|
type ConnQuery,
|
||||||
type ConnQueryResults,
|
type ConnQueryResults,
|
||||||
} from "data/connection";
|
} from "data/connection";
|
||||||
|
|
||||||
// data sqlite
|
|
||||||
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
|
export { SqliteConnection } from "data/connection/sqlite/SqliteConnection";
|
||||||
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
|
export { SqliteIntrospector } from "data/connection/sqlite/SqliteIntrospector";
|
||||||
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
|
export { SqliteLocalConnection } from "data/connection/sqlite/SqliteLocalConnection";
|
||||||
|
|
||||||
// data sqlocal
|
|
||||||
export { SQLocalConnection, sqlocal } from "data/connection/sqlite/sqlocal/SQLocalConnection";
|
|
||||||
|
|
||||||
// data postgres
|
|
||||||
export {
|
|
||||||
pg,
|
|
||||||
PgPostgresConnection,
|
|
||||||
} from "data/connection/postgres/PgPostgresConnection";
|
|
||||||
export { PostgresIntrospector } from "data/connection/postgres/PostgresIntrospector";
|
|
||||||
export { PostgresConnection } from "data/connection/postgres/PostgresConnection";
|
|
||||||
export {
|
|
||||||
postgresJs,
|
|
||||||
PostgresJsConnection,
|
|
||||||
} from "data/connection/postgres/PostgresJsConnection";
|
|
||||||
export {
|
|
||||||
createCustomPostgresConnection,
|
|
||||||
type CustomPostgresConnection,
|
|
||||||
} from "data/connection/postgres/custom";
|
|
||||||
|
|
||||||
// data prototype
|
|
||||||
export {
|
export {
|
||||||
text,
|
text,
|
||||||
number,
|
number,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { BunFile } from "bun";
|
|||||||
|
|
||||||
export async function adapterTestSuite(
|
export async function adapterTestSuite(
|
||||||
testRunner: TestRunner,
|
testRunner: TestRunner,
|
||||||
_adapter: StorageAdapter | (() => StorageAdapter),
|
adapter: StorageAdapter,
|
||||||
file: File | BunFile,
|
file: File | BunFile,
|
||||||
opts?: {
|
opts?: {
|
||||||
retries?: number;
|
retries?: number;
|
||||||
@@ -25,12 +25,7 @@ export async function adapterTestSuite(
|
|||||||
const _filename = randomString(10);
|
const _filename = randomString(10);
|
||||||
const filename = `${_filename}.png`;
|
const filename = `${_filename}.png`;
|
||||||
|
|
||||||
const getAdapter = (
|
|
||||||
typeof _adapter === "function" ? _adapter : () => _adapter
|
|
||||||
) as () => StorageAdapter;
|
|
||||||
|
|
||||||
await test("puts an object", async () => {
|
await test("puts an object", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
objects = (await adapter.listObjects()).length;
|
objects = (await adapter.listObjects()).length;
|
||||||
const result = await adapter.putObject(filename, file as unknown as File);
|
const result = await adapter.putObject(filename, file as unknown as File);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
@@ -43,7 +38,6 @@ export async function adapterTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test("lists objects", async () => {
|
await test("lists objects", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
const length = await retry(
|
const length = await retry(
|
||||||
() => adapter.listObjects().then((res) => res.length),
|
() => adapter.listObjects().then((res) => res.length),
|
||||||
(length) => length > objects,
|
(length) => length > objects,
|
||||||
@@ -55,12 +49,10 @@ export async function adapterTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test("file exists", async () => {
|
await test("file exists", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
expect(await adapter.objectExists(filename)).toBe(true);
|
expect(await adapter.objectExists(filename)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test("gets an object", async () => {
|
await test("gets an object", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, new Headers());
|
const res = await adapter.getObject(filename, new Headers());
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
||||||
@@ -70,7 +62,6 @@ export async function adapterTestSuite(
|
|||||||
if (options.testRange) {
|
if (options.testRange) {
|
||||||
await test("handles range request - partial content", async () => {
|
await test("handles range request - partial content", async () => {
|
||||||
const headers = new Headers({ Range: "bytes=0-99" });
|
const headers = new Headers({ Range: "bytes=0-99" });
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, headers);
|
const res = await adapter.getObject(filename, headers);
|
||||||
expect(res.status).toBe(206); // Partial Content
|
expect(res.status).toBe(206); // Partial Content
|
||||||
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
expect(/^bytes 0-99\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
@@ -79,7 +70,6 @@ export async function adapterTestSuite(
|
|||||||
|
|
||||||
await test("handles range request - suffix range", async () => {
|
await test("handles range request - suffix range", async () => {
|
||||||
const headers = new Headers({ Range: "bytes=-100" });
|
const headers = new Headers({ Range: "bytes=-100" });
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, headers);
|
const res = await adapter.getObject(filename, headers);
|
||||||
expect(res.status).toBe(206); // Partial Content
|
expect(res.status).toBe(206); // Partial Content
|
||||||
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
expect(/^bytes \d+-\d+\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
@@ -87,7 +77,6 @@ export async function adapterTestSuite(
|
|||||||
|
|
||||||
await test("handles invalid range request", async () => {
|
await test("handles invalid range request", async () => {
|
||||||
const headers = new Headers({ Range: "bytes=invalid" });
|
const headers = new Headers({ Range: "bytes=invalid" });
|
||||||
const adapter = getAdapter();
|
|
||||||
const res = await adapter.getObject(filename, headers);
|
const res = await adapter.getObject(filename, headers);
|
||||||
expect(res.status).toBe(416); // Range Not Satisfiable
|
expect(res.status).toBe(416); // Range Not Satisfiable
|
||||||
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
expect(/^bytes \*\/\d+$/.test(res.headers.get("Content-Range")!)).toBe(true);
|
||||||
@@ -95,7 +84,6 @@ export async function adapterTestSuite(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await test("gets object meta", async () => {
|
await test("gets object meta", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
expect(await adapter.getObjectMeta(filename)).toEqual({
|
expect(await adapter.getObjectMeta(filename)).toEqual({
|
||||||
type: file.type, // image/png
|
type: file.type, // image/png
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@@ -103,7 +91,6 @@ export async function adapterTestSuite(
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test("deletes an object", async () => {
|
await test("deletes an object", async () => {
|
||||||
const adapter = getAdapter();
|
|
||||||
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
expect(await adapter.deleteObject(filename)).toBeUndefined();
|
||||||
|
|
||||||
if (opts?.skipExistsAfterDelete !== true) {
|
if (opts?.skipExistsAfterDelete !== true) {
|
||||||
|
|||||||
@@ -10,19 +10,16 @@ export type CodeMode<AdapterConfig extends BkndConfig> = AdapterConfig extends B
|
|||||||
? BkndModeConfig<Args, AdapterConfig>
|
? BkndModeConfig<Args, AdapterConfig>
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export function code<
|
export function code<Args>(config: BkndCodeModeConfig<Args>): BkndConfig<Args> {
|
||||||
Config extends BkndConfig,
|
|
||||||
Args = Config extends BkndConfig<infer A> ? A : unknown,
|
|
||||||
>(codeConfig: CodeMode<Config>): BkndConfig<Args> {
|
|
||||||
return {
|
return {
|
||||||
...codeConfig,
|
...config,
|
||||||
app: async (args) => {
|
app: async (args) => {
|
||||||
const {
|
const {
|
||||||
config: appConfig,
|
config: appConfig,
|
||||||
plugins,
|
plugins,
|
||||||
isProd,
|
isProd,
|
||||||
syncSchemaOptions,
|
syncSchemaOptions,
|
||||||
} = await makeModeConfig(codeConfig, args);
|
} = await makeModeConfig(config, args);
|
||||||
|
|
||||||
if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
|
if (appConfig?.options?.mode && appConfig?.options?.mode !== "code") {
|
||||||
$console.warn("You should not set a different mode than `db` when using code mode");
|
$console.warn("You should not set a different mode than `db` when using code mode");
|
||||||
|
|||||||
+11
-14
@@ -1,6 +1,6 @@
|
|||||||
import type { BkndConfig } from "bknd/adapter";
|
import type { BkndConfig } from "bknd/adapter";
|
||||||
import { makeModeConfig, type BkndModeConfig } from "./shared";
|
import { makeModeConfig, type BkndModeConfig } from "./shared";
|
||||||
import { getDefaultConfig, type MaybePromise, type Merge } from "bknd";
|
import { getDefaultConfig, type MaybePromise, type ModuleConfigs, type Merge } from "bknd";
|
||||||
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
import type { DbModuleManager } from "modules/db/DbModuleManager";
|
||||||
import { invariant, $console } from "bknd/utils";
|
import { invariant, $console } from "bknd/utils";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export type BkndHybridModeOptions = {
|
|||||||
* Reader function to read the configuration from the file system.
|
* Reader function to read the configuration from the file system.
|
||||||
* This is required for hybrid mode to work.
|
* This is required for hybrid mode to work.
|
||||||
*/
|
*/
|
||||||
reader?: (path: string) => MaybePromise<string | object>;
|
reader?: (path: string) => MaybePromise<string>;
|
||||||
/**
|
/**
|
||||||
* Provided secrets to be merged into the configuration
|
* Provided secrets to be merged into the configuration
|
||||||
*/
|
*/
|
||||||
@@ -23,12 +23,8 @@ export type HybridMode<AdapterConfig extends BkndConfig> = AdapterConfig extends
|
|||||||
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
|
? BkndModeConfig<Args, Merge<BkndHybridModeOptions & AdapterConfig>>
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
export function hybrid<
|
export function hybrid<Args>(hybridConfig: HybridBkndConfig<Args>): BkndConfig<Args> {
|
||||||
Config extends BkndConfig,
|
|
||||||
Args = Config extends BkndConfig<infer A> ? A : unknown,
|
|
||||||
>(hybridConfig: HybridMode<Config>): BkndConfig<Args> {
|
|
||||||
return {
|
return {
|
||||||
...hybridConfig,
|
|
||||||
app: async (args) => {
|
app: async (args) => {
|
||||||
const {
|
const {
|
||||||
config: appConfig,
|
config: appConfig,
|
||||||
@@ -44,15 +40,16 @@ export function hybrid<
|
|||||||
}
|
}
|
||||||
invariant(
|
invariant(
|
||||||
typeof appConfig.reader === "function",
|
typeof appConfig.reader === "function",
|
||||||
"You must set a `reader` option when using hybrid mode",
|
"You must set the `reader` option when using hybrid mode",
|
||||||
);
|
);
|
||||||
|
|
||||||
const fileContent = await appConfig.reader?.(configFilePath);
|
let fileConfig: ModuleConfigs;
|
||||||
let fileConfig = typeof fileContent === "string" ? JSON.parse(fileContent) : fileContent;
|
try {
|
||||||
if (!fileConfig) {
|
fileConfig = JSON.parse(await appConfig.reader!(configFilePath)) as ModuleConfigs;
|
||||||
$console.warn("No config found, using default config");
|
} catch (e) {
|
||||||
fileConfig = getDefaultConfig();
|
const defaultConfig = (appConfig.config ?? getDefaultConfig()) as ModuleConfigs;
|
||||||
await appConfig.writer?.(configFilePath, JSON.stringify(fileConfig, null, 2));
|
await appConfig.writer!(configFilePath, JSON.stringify(defaultConfig, null, 2));
|
||||||
|
fileConfig = defaultConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+10
-24
@@ -1,7 +1,7 @@
|
|||||||
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
|
import type { AppPlugin, BkndConfig, MaybePromise, Merge } from "bknd";
|
||||||
import { syncTypes, syncConfig } from "bknd/plugins";
|
import { syncTypes, syncConfig } from "bknd/plugins";
|
||||||
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
|
import { syncSecrets } from "plugins/dev/sync-secrets.plugin";
|
||||||
import { $console } from "bknd/utils";
|
import { invariant, $console } from "bknd/utils";
|
||||||
|
|
||||||
export type BkndModeOptions = {
|
export type BkndModeOptions = {
|
||||||
/**
|
/**
|
||||||
@@ -56,14 +56,6 @@ export type BkndModeConfig<Args = any, Additional = {}> = BkndConfig<
|
|||||||
Merge<BkndModeOptions & Additional>
|
Merge<BkndModeOptions & Additional>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function _isProd() {
|
|
||||||
try {
|
|
||||||
return process.env.NODE_ENV === "production";
|
|
||||||
} catch (_e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function makeModeConfig<
|
export async function makeModeConfig<
|
||||||
Args = any,
|
Args = any,
|
||||||
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
|
Config extends BkndModeConfig<Args> = BkndModeConfig<Args>,
|
||||||
@@ -77,24 +69,25 @@ export async function makeModeConfig<
|
|||||||
|
|
||||||
if (typeof config.isProduction !== "boolean") {
|
if (typeof config.isProduction !== "boolean") {
|
||||||
$console.warn(
|
$console.warn(
|
||||||
"You should set `isProduction` option when using managed modes to prevent accidental issues with writing plugins and syncing schema. As fallback, it is set to",
|
"You should set `isProduction` option when using managed modes to prevent accidental issues",
|
||||||
_isProd(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let needsWriter = false;
|
invariant(
|
||||||
|
typeof config.writer === "function",
|
||||||
|
"You must set the `writer` option when using managed modes",
|
||||||
|
);
|
||||||
|
|
||||||
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
|
const { typesFilePath, configFilePath, writer, syncSecrets: syncSecretsOptions } = config;
|
||||||
|
|
||||||
const isProd = config.isProduction ?? _isProd();
|
const isProd = config.isProduction;
|
||||||
const plugins = config?.options?.plugins ?? ([] as AppPlugin[]);
|
const plugins = appConfig?.options?.plugins ?? ([] as AppPlugin[]);
|
||||||
const syncFallback = typeof config.syncSchema === "boolean" ? config.syncSchema : !isProd;
|
|
||||||
const syncSchemaOptions =
|
const syncSchemaOptions =
|
||||||
typeof config.syncSchema === "object"
|
typeof config.syncSchema === "object"
|
||||||
? config.syncSchema
|
? config.syncSchema
|
||||||
: {
|
: {
|
||||||
force: syncFallback,
|
force: config.syncSchema !== false,
|
||||||
drop: syncFallback,
|
drop: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isProd) {
|
if (!isProd) {
|
||||||
@@ -102,7 +95,6 @@ export async function makeModeConfig<
|
|||||||
if (plugins.some((p) => p.name === "bknd-sync-types")) {
|
if (plugins.some((p) => p.name === "bknd-sync-types")) {
|
||||||
throw new Error("You have to unregister the `syncTypes` plugin");
|
throw new Error("You have to unregister the `syncTypes` plugin");
|
||||||
}
|
}
|
||||||
needsWriter = true;
|
|
||||||
plugins.push(
|
plugins.push(
|
||||||
syncTypes({
|
syncTypes({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -122,7 +114,6 @@ export async function makeModeConfig<
|
|||||||
if (plugins.some((p) => p.name === "bknd-sync-config")) {
|
if (plugins.some((p) => p.name === "bknd-sync-config")) {
|
||||||
throw new Error("You have to unregister the `syncConfig` plugin");
|
throw new Error("You have to unregister the `syncConfig` plugin");
|
||||||
}
|
}
|
||||||
needsWriter = true;
|
|
||||||
plugins.push(
|
plugins.push(
|
||||||
syncConfig({
|
syncConfig({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -151,7 +142,6 @@ export async function makeModeConfig<
|
|||||||
.join(".");
|
.join(".");
|
||||||
}
|
}
|
||||||
|
|
||||||
needsWriter = true;
|
|
||||||
plugins.push(
|
plugins.push(
|
||||||
syncSecrets({
|
syncSecrets({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -184,10 +174,6 @@ export async function makeModeConfig<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsWriter && typeof config.writer !== "function") {
|
|
||||||
$console.warn("You must set a `writer` function, attempts to write will fail");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
isProd,
|
isProd,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export type ModuleManagerOptions = {
|
|||||||
verbosity?: Verbosity;
|
verbosity?: Verbosity;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debug_modules = env("modules_debug", false);
|
const debug_modules = env("modules_debug");
|
||||||
|
|
||||||
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
abstract class ModuleManagerEvent<A = {}> extends Event<{ ctx: ModuleBuildContext } & A> {}
|
||||||
export class ModuleManagerConfigUpdateEvent<
|
export class ModuleManagerConfigUpdateEvent<
|
||||||
@@ -223,7 +223,7 @@ export class ModuleManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extractSecrets() {
|
extractSecrets() {
|
||||||
const moduleConfigs = JSON.parse(JSON.stringify(this.configs()));
|
const moduleConfigs = structuredClone(this.configs());
|
||||||
const secrets = { ...this.options?.secrets };
|
const secrets = { ...this.options?.secrets };
|
||||||
const extractedKeys: string[] = [];
|
const extractedKeys: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -290,12 +290,13 @@ export class DbModuleManager extends ModuleManager {
|
|||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (e instanceof TransformPersistFailedException) {
|
||||||
|
$console.error("ModuleManager: Cannot save invalid config");
|
||||||
|
this.revertModules();
|
||||||
|
throw e;
|
||||||
} else {
|
} else {
|
||||||
if (e instanceof TransformPersistFailedException) {
|
|
||||||
$console.error("ModuleManager: Cannot save invalid config");
|
|
||||||
}
|
|
||||||
$console.error("ModuleManager: Aborting");
|
$console.error("ModuleManager: Aborting");
|
||||||
await this.revertModules();
|
this.revertModules();
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,3 @@ export const schemaRead = new Permission(
|
|||||||
);
|
);
|
||||||
export const build = new Permission("system.build");
|
export const build = new Permission("system.build");
|
||||||
export const mcp = new Permission("system.mcp");
|
export const mcp = new Permission("system.mcp");
|
||||||
export const info = new Permission("system.info");
|
|
||||||
export const openapi = new Permission("system.openapi");
|
|
||||||
|
|||||||
@@ -105,10 +105,7 @@ export class AppServer extends Module<AppServerConfig> {
|
|||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
if (isDebug()) {
|
if (isDebug()) {
|
||||||
return c.json(
|
return c.json({ error: err.message, stack: err.stack }, 500);
|
||||||
{ error: err.message, stack: err.stack?.split("\n").map((line) => line.trim()) },
|
|
||||||
500,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference types="@cloudflare/workers-types" />
|
||||||
|
|
||||||
import type { App } from "App";
|
import type { App } from "App";
|
||||||
import {
|
import {
|
||||||
datetimeStringLocal,
|
datetimeStringLocal,
|
||||||
@@ -123,7 +125,7 @@ export class SystemController extends Controller {
|
|||||||
private registerConfigController(client: Hono<any>): void {
|
private registerConfigController(client: Hono<any>): void {
|
||||||
const { permission } = this.middlewares;
|
const { permission } = this.middlewares;
|
||||||
// don't add auth again, it's already added in getController
|
// don't add auth again, it's already added in getController
|
||||||
const hono = this.create();
|
const hono = this.create(); /* .use(permission(SystemPermissions.configRead)); */
|
||||||
|
|
||||||
if (!this.app.isReadOnly()) {
|
if (!this.app.isReadOnly()) {
|
||||||
const manager = this.app.modules as DbModuleManager;
|
const manager = this.app.modules as DbModuleManager;
|
||||||
@@ -315,11 +317,6 @@ export class SystemController extends Controller {
|
|||||||
summary: "Get the config for a module",
|
summary: "Get the config for a module",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
}),
|
}),
|
||||||
permission(SystemPermissions.configRead, {
|
|
||||||
context: (c) => ({
|
|
||||||
module: c.req.param("module"),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
mcpTool("system_config", {
|
mcpTool("system_config", {
|
||||||
annotations: {
|
annotations: {
|
||||||
readOnlyHint: true,
|
readOnlyHint: true,
|
||||||
@@ -357,7 +354,7 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
override getController() {
|
override getController() {
|
||||||
const { permission, auth } = this.middlewares;
|
const { permission, auth } = this.middlewares;
|
||||||
const hono = this.create().use(auth()).use(permission(SystemPermissions.accessApi, {}));
|
const hono = this.create().use(auth());
|
||||||
|
|
||||||
this.registerConfigController(hono);
|
this.registerConfigController(hono);
|
||||||
|
|
||||||
@@ -432,9 +429,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/permissions",
|
"/permissions",
|
||||||
permission(SystemPermissions.schemaRead, {
|
|
||||||
context: (_c) => ({ module: "auth" }),
|
|
||||||
}),
|
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Get the permissions",
|
summary: "Get the permissions",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
@@ -447,7 +441,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.post(
|
hono.post(
|
||||||
"/build",
|
"/build",
|
||||||
permission(SystemPermissions.build, {}),
|
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Build the app",
|
summary: "Build the app",
|
||||||
tags: ["system"],
|
tags: ["system"],
|
||||||
@@ -478,7 +471,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/info",
|
"/info",
|
||||||
permission(SystemPermissions.info, {}),
|
|
||||||
mcpTool("system_info"),
|
mcpTool("system_info"),
|
||||||
describeRoute({
|
describeRoute({
|
||||||
summary: "Get the server info",
|
summary: "Get the server info",
|
||||||
@@ -512,7 +504,6 @@ export class SystemController extends Controller {
|
|||||||
|
|
||||||
hono.get(
|
hono.get(
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
permission(SystemPermissions.openapi, {}),
|
|
||||||
openAPISpecs(this.ctx.server, {
|
openAPISpecs(this.ctx.server, {
|
||||||
info: {
|
info: {
|
||||||
title: "bknd API",
|
title: "bknd API",
|
||||||
@@ -520,11 +511,7 @@ export class SystemController extends Controller {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
hono.get(
|
hono.get("/swagger", swaggerUI({ url: "/api/system/openapi.json" }));
|
||||||
"/swagger",
|
|
||||||
permission(SystemPermissions.openapi, {}),
|
|
||||||
swaggerUI({ url: "/api/system/openapi.json" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
return hono;
|
return hono;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,683 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, mock, test, setSystemTime } from "bun:test";
|
|
||||||
import { emailOTP } from "./email-otp.plugin";
|
|
||||||
import { createApp } from "core/test/utils";
|
|
||||||
import { disableConsoleLog, enableConsoleLog } from "core/utils/test";
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
|
||||||
afterAll(enableConsoleLog);
|
|
||||||
|
|
||||||
describe("otp plugin", () => {
|
|
||||||
test("should not work if auth is not enabled", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
options: {
|
|
||||||
plugins: [emailOTP({ showActualErrors: true })],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should require email driver if sendEmail is true", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [emailOTP()],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(404);
|
|
||||||
|
|
||||||
{
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [emailOTP({ sendEmail: false })],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
const res = await app.server.request("/api/auth/otp/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should prevent mutations of the OTP entity", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [emailOTP({ showActualErrors: true })],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
email: "test@test.com",
|
|
||||||
code: "123456",
|
|
||||||
action: "login",
|
|
||||||
created_at: new Date(),
|
|
||||||
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24),
|
|
||||||
used_at: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(app.em.mutator("users_otp").insertOne(payload)).rejects.toThrow();
|
|
||||||
expect(
|
|
||||||
await app
|
|
||||||
.getApi()
|
|
||||||
.data.createOne("users_otp", payload)
|
|
||||||
.then((r) => r.ok),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should generate a token", async () => {
|
|
||||||
const called = mock(() => null);
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [emailOTP({ showActualErrors: true })],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async (to) => {
|
|
||||||
expect(to).toBe("test@test.com");
|
|
||||||
called();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
expect(data.sent).toBe(true);
|
|
||||||
expect(data.data.email).toBe("test@test.com");
|
|
||||||
expect(data.data.action).toBe("login");
|
|
||||||
expect(data.data.expires_at).toBeDefined();
|
|
||||||
|
|
||||||
{
|
|
||||||
const { data } = await app.em.fork().repo("users_otp").findOne({ email: "test@test.com" });
|
|
||||||
expect(data?.code).toBeDefined();
|
|
||||||
expect(data?.code?.length).toBe(6);
|
|
||||||
expect(data?.code?.split("").every((char: string) => Number.isInteger(Number(char)))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(data?.email).toBe("test@test.com");
|
|
||||||
}
|
|
||||||
expect(called).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should login with a code", async () => {
|
|
||||||
let code = "";
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async (to, _subject, body) => {
|
|
||||||
expect(to).toBe("test@test.com");
|
|
||||||
code = String(body);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get("set-cookie")).toBeDefined();
|
|
||||||
const userData = (await res.json()) as any;
|
|
||||||
expect(userData.user.email).toBe("test@test.com");
|
|
||||||
expect(userData.token).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should register with a code", async () => {
|
|
||||||
let code = "";
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async (to, _subject, body) => {
|
|
||||||
expect(to).toBe("test@test.com");
|
|
||||||
code = String(body);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const res = await app.server.request("/api/auth/otp/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
expect(data.sent).toBe(true);
|
|
||||||
expect(data.data.email).toBe("test@test.com");
|
|
||||||
expect(data.data.action).toBe("register");
|
|
||||||
expect(data.data.expires_at).toBeDefined();
|
|
||||||
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get("set-cookie")).toBeDefined();
|
|
||||||
const userData = (await res.json()) as any;
|
|
||||||
expect(userData.user.email).toBe("test@test.com");
|
|
||||||
expect(userData.token).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should not send email if sendEmail is false", async () => {
|
|
||||||
const called = mock(() => null);
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [emailOTP({ sendEmail: false })],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async () => {
|
|
||||||
called();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
const res = await app.server.request("/api/auth/otp/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(201);
|
|
||||||
expect(called).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject invalid codes", async () => {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
// First send a code
|
|
||||||
await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try to use an invalid code
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code: "999999" }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const error = await res.json();
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject code reuse", async () => {
|
|
||||||
let code = "";
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async (_to, _subject, body) => {
|
|
||||||
code = String(body);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
// Send a code
|
|
||||||
await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the code successfully
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to use the same code again
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const error = await res.json();
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject expired codes", async () => {
|
|
||||||
// Set a fixed system time
|
|
||||||
const baseTime = Date.now();
|
|
||||||
setSystemTime(new Date(baseTime));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
ttl: 1, // 1 second TTL
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
// Send a code
|
|
||||||
const sendRes = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
expect(sendRes.status).toBe(201);
|
|
||||||
|
|
||||||
// Get the code from the database
|
|
||||||
const { data: otpData } = await app.em
|
|
||||||
.fork()
|
|
||||||
.repo("users_otp")
|
|
||||||
.findOne({ email: "test@test.com" });
|
|
||||||
expect(otpData?.code).toBeDefined();
|
|
||||||
|
|
||||||
// Advance system time by more than 1 second to expire the code
|
|
||||||
setSystemTime(new Date(baseTime + 1100));
|
|
||||||
|
|
||||||
// Try to use the expired code
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code: otpData?.code }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const error = await res.json();
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
} finally {
|
|
||||||
// Reset system time
|
|
||||||
setSystemTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should reject codes with different actions", async () => {
|
|
||||||
let loginCode = "";
|
|
||||||
let registerCode = "";
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
|
|
||||||
// Send a login code
|
|
||||||
await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the login code
|
|
||||||
const { data: loginOtp } = await app
|
|
||||||
.getApi()
|
|
||||||
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "login" } });
|
|
||||||
loginCode = loginOtp?.code || "";
|
|
||||||
|
|
||||||
// Send a register code
|
|
||||||
await app.server.request("/api/auth/otp/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the register code
|
|
||||||
const { data: registerOtp } = await app
|
|
||||||
.getApi()
|
|
||||||
.data.readOneBy("users_otp", { where: { email: "test@test.com", action: "register" } });
|
|
||||||
registerCode = registerOtp?.code || "";
|
|
||||||
|
|
||||||
// Try to use login code for register
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code: loginCode }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const error = await res.json();
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to use register code for login
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code: registerCode }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const error = await res.json();
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should invalidate previous codes when sending new code", async () => {
|
|
||||||
let firstCode = "";
|
|
||||||
let secondCode = "";
|
|
||||||
|
|
||||||
const app = createApp({
|
|
||||||
config: {
|
|
||||||
auth: {
|
|
||||||
enabled: true,
|
|
||||||
jwt: {
|
|
||||||
secret: "test",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
plugins: [
|
|
||||||
emailOTP({
|
|
||||||
showActualErrors: true,
|
|
||||||
generateEmail: (otp) => ({ subject: "test", body: otp.code }),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
drivers: {
|
|
||||||
email: {
|
|
||||||
send: async () => {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
seed: async (ctx) => {
|
|
||||||
await ctx.app.createUser({ email: "test@test.com", password: "12345678" });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await app.build();
|
|
||||||
const em = app.em.fork();
|
|
||||||
|
|
||||||
// Send first code
|
|
||||||
await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the first code
|
|
||||||
const { data: firstOtp } = await em
|
|
||||||
.repo("users_otp")
|
|
||||||
.findOne({ email: "test@test.com", action: "login" });
|
|
||||||
firstCode = firstOtp?.code || "";
|
|
||||||
expect(firstCode).toBeDefined();
|
|
||||||
|
|
||||||
// Send second code (should invalidate the first)
|
|
||||||
await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get the second code
|
|
||||||
const { data: secondOtp } = await em
|
|
||||||
.repo("users_otp")
|
|
||||||
.findOne({ email: "test@test.com", action: "login" });
|
|
||||||
secondCode = secondOtp?.code || "";
|
|
||||||
expect(secondCode).toBeDefined();
|
|
||||||
expect(secondCode).not.toBe(firstCode);
|
|
||||||
|
|
||||||
// Try to use the first code (should fail as it's been invalidated)
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code: firstCode }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
const error = await res.json();
|
|
||||||
expect(error).toBeDefined();
|
|
||||||
}
|
|
||||||
|
|
||||||
// The second code should work
|
|
||||||
{
|
|
||||||
const res = await app.server.request("/api/auth/otp/login", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ email: "test@test.com", code: secondCode }),
|
|
||||||
});
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
import {
|
|
||||||
datetime,
|
|
||||||
em,
|
|
||||||
entity,
|
|
||||||
enumm,
|
|
||||||
Exception,
|
|
||||||
text,
|
|
||||||
type App,
|
|
||||||
type AppPlugin,
|
|
||||||
type DB,
|
|
||||||
type FieldSchema,
|
|
||||||
type MaybePromise,
|
|
||||||
type EntityConfig,
|
|
||||||
DatabaseEvents,
|
|
||||||
} from "bknd";
|
|
||||||
import {
|
|
||||||
invariant,
|
|
||||||
s,
|
|
||||||
jsc,
|
|
||||||
HttpStatus,
|
|
||||||
threwAsync,
|
|
||||||
randomString,
|
|
||||||
$console,
|
|
||||||
pickKeys,
|
|
||||||
} from "bknd/utils";
|
|
||||||
import { Hono } from "hono";
|
|
||||||
|
|
||||||
export type EmailOTPPluginOptions = {
|
|
||||||
/**
|
|
||||||
* Customize code generation. If not provided, a random 6-digit code will be generated.
|
|
||||||
*/
|
|
||||||
generateCode?: (user: Pick<DB["users"], "email">) => string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The base path for the API endpoints.
|
|
||||||
* @default "/api/auth/otp"
|
|
||||||
*/
|
|
||||||
apiBasePath?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The TTL for the OTP tokens in seconds.
|
|
||||||
* @default 600 (10 minutes)
|
|
||||||
*/
|
|
||||||
ttl?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the OTP entity.
|
|
||||||
* @default "users_otp"
|
|
||||||
*/
|
|
||||||
entity?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The config for the OTP entity.
|
|
||||||
*/
|
|
||||||
entityConfig?: EntityConfig;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Customize email content. If not provided, a default email will be sent.
|
|
||||||
*/
|
|
||||||
generateEmail?: (
|
|
||||||
otp: EmailOTPFieldSchema,
|
|
||||||
) => MaybePromise<{ subject: string; body: string | { text: string; html: string } }>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable debug mode for error messages.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
showActualErrors?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allow direct mutations (create/update) of OTP codes outside of this plugin,
|
|
||||||
* e.g. via API or admin UI. If false, mutations are only allowed via the plugin's flows.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
allowExternalMutations?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to send the email with the OTP code.
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
sendEmail?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const otpFields = {
|
|
||||||
action: enumm({
|
|
||||||
enum: ["login", "register"],
|
|
||||||
}),
|
|
||||||
code: text().required(),
|
|
||||||
email: text().required(),
|
|
||||||
created_at: datetime(),
|
|
||||||
expires_at: datetime().required(),
|
|
||||||
used_at: datetime(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EmailOTPFieldSchema = FieldSchema<typeof otpFields>;
|
|
||||||
|
|
||||||
class OTPError extends Exception {
|
|
||||||
override name = "OTPError";
|
|
||||||
override code = HttpStatus.BAD_REQUEST;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function emailOTP({
|
|
||||||
generateCode: _generateCode,
|
|
||||||
apiBasePath = "/api/auth/otp",
|
|
||||||
ttl = 600,
|
|
||||||
entity: entityName = "users_otp",
|
|
||||||
entityConfig,
|
|
||||||
generateEmail: _generateEmail,
|
|
||||||
showActualErrors = false,
|
|
||||||
allowExternalMutations = false,
|
|
||||||
sendEmail = true,
|
|
||||||
}: EmailOTPPluginOptions = {}): AppPlugin {
|
|
||||||
return (app: App) => {
|
|
||||||
return {
|
|
||||||
name: "email-otp",
|
|
||||||
schema: () =>
|
|
||||||
em(
|
|
||||||
{
|
|
||||||
[entityName]: entity(
|
|
||||||
entityName,
|
|
||||||
otpFields,
|
|
||||||
{
|
|
||||||
name: "Users OTP",
|
|
||||||
sort_dir: "desc",
|
|
||||||
primary_format: app.module.data.config.default_primary_format,
|
|
||||||
...entityConfig,
|
|
||||||
},
|
|
||||||
"generated",
|
|
||||||
) as any,
|
|
||||||
},
|
|
||||||
({ index }, schema) => {
|
|
||||||
const otp = schema[entityName]!;
|
|
||||||
index(otp).on(["email", "expires_at", "code"]);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onBuilt: async () => {
|
|
||||||
const auth = app.module.auth;
|
|
||||||
invariant(auth && auth.enabled === true, "Auth is not enabled");
|
|
||||||
invariant(!sendEmail || app.drivers?.email, "Email driver is not registered");
|
|
||||||
|
|
||||||
const generateCode =
|
|
||||||
_generateCode ?? (() => Math.floor(100000 + Math.random() * 900000).toString());
|
|
||||||
const generateEmail =
|
|
||||||
_generateEmail ??
|
|
||||||
((otp: EmailOTPFieldSchema) => ({
|
|
||||||
subject: "OTP Code",
|
|
||||||
body: `Your OTP code is: ${otp.code}`,
|
|
||||||
}));
|
|
||||||
const em = app.em.fork();
|
|
||||||
|
|
||||||
const hono = new Hono()
|
|
||||||
.post(
|
|
||||||
"/login",
|
|
||||||
jsc(
|
|
||||||
"json",
|
|
||||||
s.object({
|
|
||||||
email: s.string({ format: "email" }),
|
|
||||||
code: s.string({ minLength: 1 }).optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
jsc("query", s.object({ redirect: s.string().optional() })),
|
|
||||||
async (c) => {
|
|
||||||
const { email, code } = c.req.valid("json");
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
const user = await findUser(app, email);
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
const otpData = await getValidatedCode(
|
|
||||||
app,
|
|
||||||
entityName,
|
|
||||||
email,
|
|
||||||
code,
|
|
||||||
"login",
|
|
||||||
);
|
|
||||||
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
|
||||||
|
|
||||||
const jwt = await auth.authenticator.jwt(user);
|
|
||||||
// @ts-expect-error private method
|
|
||||||
return auth.authenticator.respondWithUser(
|
|
||||||
c,
|
|
||||||
{ user, token: jwt },
|
|
||||||
{ redirect },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const otpData = await invalidateAndGenerateCode(
|
|
||||||
app,
|
|
||||||
{ generateCode, ttl, entity: entityName },
|
|
||||||
user,
|
|
||||||
"login",
|
|
||||||
);
|
|
||||||
if (sendEmail) {
|
|
||||||
await sendCode(app, otpData, { generateEmail });
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
sent: true,
|
|
||||||
data: pickKeys(otpData, ["email", "action", "expires_at"]),
|
|
||||||
},
|
|
||||||
HttpStatus.CREATED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/register",
|
|
||||||
jsc(
|
|
||||||
"json",
|
|
||||||
s.object({
|
|
||||||
email: s.string({ format: "email" }),
|
|
||||||
code: s.string({ minLength: 1 }).optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
jsc("query", s.object({ redirect: s.string().optional() })),
|
|
||||||
async (c) => {
|
|
||||||
const { email, code } = c.req.valid("json");
|
|
||||||
const { redirect } = c.req.valid("query");
|
|
||||||
|
|
||||||
// throw if user exists
|
|
||||||
if (!(await threwAsync(findUser(app, email)))) {
|
|
||||||
throw new Exception("User already exists", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code) {
|
|
||||||
const otpData = await getValidatedCode(
|
|
||||||
app,
|
|
||||||
entityName,
|
|
||||||
email,
|
|
||||||
code,
|
|
||||||
"register",
|
|
||||||
);
|
|
||||||
await em.mutator(entityName).updateOne(otpData.id, { used_at: new Date() });
|
|
||||||
|
|
||||||
const user = await app.createUser({
|
|
||||||
email,
|
|
||||||
password: randomString(32, true),
|
|
||||||
});
|
|
||||||
|
|
||||||
const jwt = await auth.authenticator.jwt(user);
|
|
||||||
// @ts-expect-error private method
|
|
||||||
return auth.authenticator.respondWithUser(
|
|
||||||
c,
|
|
||||||
{ user, token: jwt },
|
|
||||||
{ redirect },
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const otpData = await invalidateAndGenerateCode(
|
|
||||||
app,
|
|
||||||
{ generateCode, ttl, entity: entityName },
|
|
||||||
{ email },
|
|
||||||
"register",
|
|
||||||
);
|
|
||||||
if (sendEmail) {
|
|
||||||
await sendCode(app, otpData, { generateEmail });
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
sent: true,
|
|
||||||
data: pickKeys(otpData, ["email", "action", "expires_at"]),
|
|
||||||
},
|
|
||||||
HttpStatus.CREATED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.onError((err) => {
|
|
||||||
if (showActualErrors || err instanceof OTPError) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Exception("Invalid credentials", HttpStatus.BAD_REQUEST);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.server.route(apiBasePath, hono);
|
|
||||||
|
|
||||||
if (allowExternalMutations !== true) {
|
|
||||||
registerListeners(app, entityName);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findUser(app: App, email: string) {
|
|
||||||
const user_entity = app.module.auth.config.entity_name as "users";
|
|
||||||
const { data: user } = await app.em.repo(user_entity).findOne({ email });
|
|
||||||
if (!user) {
|
|
||||||
throw new Exception("User not found", HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invalidateAndGenerateCode(
|
|
||||||
app: App,
|
|
||||||
opts: Required<Pick<EmailOTPPluginOptions, "generateCode" | "ttl" | "entity">>,
|
|
||||||
user: Pick<DB["users"], "email">,
|
|
||||||
action: EmailOTPFieldSchema["action"],
|
|
||||||
) {
|
|
||||||
const { generateCode, ttl, entity: entityName } = opts;
|
|
||||||
const newCode = generateCode?.(user);
|
|
||||||
if (!newCode) {
|
|
||||||
throw new OTPError("Failed to generate code");
|
|
||||||
}
|
|
||||||
|
|
||||||
await invalidateAllUserCodes(app, entityName, user.email, ttl);
|
|
||||||
const { data: otpData } = await app.em
|
|
||||||
.fork()
|
|
||||||
.mutator(entityName)
|
|
||||||
.insertOne({
|
|
||||||
code: newCode,
|
|
||||||
email: user.email,
|
|
||||||
action,
|
|
||||||
created_at: new Date(),
|
|
||||||
expires_at: new Date(Date.now() + ttl * 1000),
|
|
||||||
});
|
|
||||||
|
|
||||||
$console.log("[OTP Code]", newCode);
|
|
||||||
|
|
||||||
return otpData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendCode(
|
|
||||||
app: App,
|
|
||||||
otpData: EmailOTPFieldSchema,
|
|
||||||
opts: Required<Pick<EmailOTPPluginOptions, "generateEmail">>,
|
|
||||||
) {
|
|
||||||
const { generateEmail } = opts;
|
|
||||||
const { subject, body } = await generateEmail(otpData);
|
|
||||||
await app.drivers?.email?.send(otpData.email, subject, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getValidatedCode(
|
|
||||||
app: App,
|
|
||||||
entityName: string,
|
|
||||||
email: string,
|
|
||||||
code: string,
|
|
||||||
action: EmailOTPFieldSchema["action"],
|
|
||||||
) {
|
|
||||||
invariant(email, "[OTP Plugin]: Email is required");
|
|
||||||
invariant(code, "[OTP Plugin]: Code is required");
|
|
||||||
const em = app.em.fork();
|
|
||||||
const { data: otpData } = await em.repo(entityName).findOne({ email, code, action });
|
|
||||||
if (!otpData) {
|
|
||||||
throw new OTPError("Invalid code");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otpData.expires_at < new Date()) {
|
|
||||||
throw new OTPError("Code expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otpData.used_at) {
|
|
||||||
throw new OTPError("Code already used");
|
|
||||||
}
|
|
||||||
|
|
||||||
return otpData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invalidateAllUserCodes(app: App, entityName: string, email: string, ttl: number) {
|
|
||||||
invariant(ttl > 0, "[OTP Plugin]: TTL must be greater than 0");
|
|
||||||
invariant(email, "[OTP Plugin]: Email is required");
|
|
||||||
const em = app.em.fork();
|
|
||||||
await em
|
|
||||||
.mutator(entityName)
|
|
||||||
.updateWhere(
|
|
||||||
{ expires_at: new Date(Date.now() - 1000) },
|
|
||||||
{ email, used_at: { $isnull: true } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerListeners(app: App, entityName: string) {
|
|
||||||
[DatabaseEvents.MutatorInsertBefore, DatabaseEvents.MutatorUpdateBefore].forEach((event) => {
|
|
||||||
app.emgr.onEvent(
|
|
||||||
event,
|
|
||||||
(e: { params: { entity: { name: string } } }) => {
|
|
||||||
if (e.params.entity.name === entityName) {
|
|
||||||
throw new OTPError("Mutations of the OTP entity are not allowed");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mode: "sync",
|
|
||||||
id: "bknd-email-otp",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -8,4 +8,3 @@ export { syncConfig, type SyncConfigOptions } from "./dev/sync-config.plugin";
|
|||||||
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
export { syncTypes, type SyncTypesOptions } from "./dev/sync-types.plugin";
|
||||||
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
export { syncSecrets, type SyncSecretsOptions } from "./dev/sync-secrets.plugin";
|
||||||
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
export { timestamps, type TimestampsPluginOptions } from "./data/timestamps.plugin";
|
||||||
export { emailOTP, type EmailOTPPluginOptions } from "./auth/email-otp.plugin";
|
|
||||||
|
|||||||
+20
-22
@@ -5,7 +5,7 @@ import { BkndProvider } from "ui/client/bknd";
|
|||||||
import { useTheme, type AppTheme } from "ui/client/use-theme";
|
import { useTheme, type AppTheme } from "ui/client/use-theme";
|
||||||
import { Logo } from "ui/components/display/Logo";
|
import { Logo } from "ui/components/display/Logo";
|
||||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||||
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "bknd/client";
|
import { ClientProvider, useBkndWindowContext, type ClientProviderProps } from "./client";
|
||||||
import { createMantineTheme } from "./lib/mantine/theme";
|
import { createMantineTheme } from "./lib/mantine/theme";
|
||||||
import { Routes } from "./routes";
|
import { Routes } from "./routes";
|
||||||
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
import type { BkndAdminAppShellOptions, BkndAdminEntitiesOptions } from "./options";
|
||||||
@@ -52,30 +52,26 @@ export type BkndAdminProps = {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Admin(props: BkndAdminProps) {
|
export default function Admin({
|
||||||
|
baseUrl: baseUrlOverride,
|
||||||
|
withProvider = false,
|
||||||
|
config: _config = {},
|
||||||
|
children,
|
||||||
|
}: BkndAdminProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
const Provider = ({ children }: any) =>
|
const Provider = ({ children }: any) =>
|
||||||
props.withProvider ? (
|
withProvider ? (
|
||||||
<ClientProvider
|
<ClientProvider
|
||||||
baseUrl={props.baseUrl}
|
baseUrl={baseUrlOverride}
|
||||||
{...(typeof props.withProvider === "object" ? props.withProvider : {})}
|
{...(typeof withProvider === "object" ? withProvider : {})}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
|
||||||
<Provider>
|
|
||||||
<AdminInner {...props} />
|
|
||||||
</Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminInner(props: BkndAdminProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const config = {
|
const config = {
|
||||||
...props.config,
|
..._config,
|
||||||
...useBkndWindowContext(),
|
...useBkndWindowContext(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,12 +82,14 @@ function AdminInner(props: BkndAdminProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MantineProvider {...createMantineTheme(theme as any)}>
|
<Provider>
|
||||||
<Notifications position="top-right" />
|
<MantineProvider {...createMantineTheme(theme as any)}>
|
||||||
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
<Notifications position="top-right" />
|
||||||
{props.children}
|
<Routes BkndWrapper={BkndWrapper} basePath={config?.basepath}>
|
||||||
</Routes>
|
{children}
|
||||||
</MantineProvider>
|
</Routes>
|
||||||
|
</MantineProvider>
|
||||||
|
</Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useApi } from "bknd/client";
|
import { useApi } from "ui/client";
|
||||||
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
import { type TSchemaActions, getSchemaActions } from "./schema/actions";
|
||||||
import { AppReduced } from "./utils/AppReduced";
|
import { AppReduced } from "./utils/AppReduced";
|
||||||
import { Message } from "ui/components/display/Message";
|
import { Message } from "ui/components/display/Message";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user