Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05e0086fcd | ||
|
|
282c9f6054 | ||
|
|
b294ee68ac | ||
|
|
e8a256af2a | ||
|
|
8c596781ff | ||
|
|
43cbdb3010 | ||
|
|
b3c9f258de |
@@ -16,8 +16,8 @@ jobs:
|
|||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_DB: bknd
|
POSTGRES_DB: bknd
|
||||||
ports:
|
# ports:
|
||||||
- 5430:5432
|
# - 5430:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
|
|||||||
Vendored
+8
-5
@@ -1,19 +1,22 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||||
"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",
|
"js/ts.preferences.importModuleSpecifier": "non-relative",
|
||||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
"js/ts.preferences.autoImportFileExcludePatterns": [
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"**/node_modules/**/dist/**",
|
"**/node_modules/**/dist/**",
|
||||||
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
|
"**/node_modules/**/!(src|lib|esm)/**" // optional, stricter
|
||||||
],
|
],
|
||||||
"typescript.preferences.includePackageJsonAutoImports": "on",
|
"js/ts.preferences.includePackageJsonAutoImports": "on",
|
||||||
"typescript.tsserver.watchOptions": {
|
"js/ts.tsserver.watchOptions": {
|
||||||
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
|
"excludeDirectories": ["**/dist", "**/node_modules/**/dist"]
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,73 +4,72 @@ import { disableConsoleLog, enableConsoleLog, omitKeys } from "core/utils";
|
|||||||
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
import { adapterTestSuite } from "adapter/adapter-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
const stripConnection = <T extends Record<string, any>>(cfg: T) =>
|
const stripConnection = <T extends Record<string, any>>(cfg: T) => omitKeys(cfg, ["connection"]);
|
||||||
omitKeys(cfg, ["connection"]);
|
|
||||||
|
|
||||||
beforeAll(disableConsoleLog);
|
beforeAll(disableConsoleLog);
|
||||||
afterAll(enableConsoleLog);
|
afterAll(enableConsoleLog);
|
||||||
|
|
||||||
describe("adapter", () => {
|
describe("adapter", () => {
|
||||||
describe("makeConfig", () => {
|
describe("makeConfig", () => {
|
||||||
it("returns empty config for empty inputs", async () => {
|
it("returns empty config for empty inputs", async () => {
|
||||||
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
const cases: Array<Parameters<typeof adapter.makeConfig>> = [
|
||||||
[{}],
|
[{}],
|
||||||
[{}, { env: { TEST: "test" } }],
|
[{}, { env: { TEST: "test" } }],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const args of cases) {
|
for (const args of cases) {
|
||||||
const cfg = await adapter.makeConfig(...(args as any));
|
const cfg = await adapter.makeConfig(...(args as any));
|
||||||
expect(stripConnection(cfg)).toEqual({});
|
expect(stripConnection(cfg)).toEqual({});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it("merges app output into config", async () => {
|
|
||||||
const cfg = await adapter.makeConfig(
|
|
||||||
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
|
||||||
{ env: { TEST: "test" } },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(stripConnection(cfg)).toEqual({
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("allows all properties in app() result", async () => {
|
it("merges app output into config", async () => {
|
||||||
const called = mock(() => null);
|
const cfg = await adapter.makeConfig(
|
||||||
|
{ app: (a) => ({ config: { server: { cors: { origin: a.env.TEST } } } }) },
|
||||||
|
{ env: { TEST: "test" } },
|
||||||
|
);
|
||||||
|
|
||||||
const cfg = await adapter.makeConfig(
|
expect(stripConnection(cfg)).toEqual({
|
||||||
{
|
|
||||||
app: (env) => ({
|
|
||||||
connection: { url: "test" },
|
|
||||||
config: { server: { cors: { origin: "test" } } },
|
config: { server: { cors: { origin: "test" } } },
|
||||||
options: { mode: "db" as const },
|
});
|
||||||
onBuilt: () => {
|
});
|
||||||
called();
|
|
||||||
expect(env).toEqual({ foo: "bar" });
|
it("allows all properties in app() result", async () => {
|
||||||
|
const called = mock(() => null);
|
||||||
|
|
||||||
|
const cfg = await adapter.makeConfig(
|
||||||
|
{
|
||||||
|
app: (env) => ({
|
||||||
|
connection: { url: "test" },
|
||||||
|
config: { server: { cors: { origin: "test" } } },
|
||||||
|
options: { mode: "db" as const },
|
||||||
|
onBuilt: () => {
|
||||||
|
called();
|
||||||
|
expect(env).toEqual({ foo: "bar" });
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}),
|
{ foo: "bar" },
|
||||||
},
|
);
|
||||||
{ foo: "bar" },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(cfg.connection).toEqual({ url: "test" });
|
expect(cfg.connection).toEqual({ url: "test" });
|
||||||
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
expect(cfg.config).toEqual({ server: { cors: { origin: "test" } } });
|
||||||
expect(cfg.options).toEqual({ mode: "db" });
|
expect(cfg.options).toEqual({ mode: "db" });
|
||||||
|
|
||||||
await cfg.onBuilt?.({} as any);
|
await cfg.onBuilt?.({} as any);
|
||||||
expect(called).toHaveBeenCalledTimes(1);
|
expect(called).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("adapter test suites", () => {
|
describe("adapter test suites", () => {
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createFrameworkApp,
|
makeApp: adapter.createFrameworkApp,
|
||||||
label: "framework app",
|
label: "framework app",
|
||||||
});
|
});
|
||||||
|
|
||||||
adapterTestSuite(bunTestRunner, {
|
adapterTestSuite(bunTestRunner, {
|
||||||
makeApp: adapter.createRuntimeApp,
|
makeApp: adapter.createRuntimeApp,
|
||||||
label: "runtime app",
|
label: "runtime app",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,13 @@ describe("Api", async () => {
|
|||||||
|
|
||||||
it("should extract tokens case insensitive", async () => {
|
it("should extract tokens case insensitive", async () => {
|
||||||
const token = await sign({ sub: "test" }, "1234");
|
const token = await sign({ sub: "test" }, "1234");
|
||||||
expect(new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState().token).toBe(token);
|
expect(
|
||||||
expect(new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState().token).toBe(token);
|
new Api({ headers: new Headers({ Authorization: `Bearer ${token}` }) }).getAuthState()
|
||||||
})
|
.token,
|
||||||
|
).toBe(token);
|
||||||
|
expect(
|
||||||
|
new Api({ headers: new Headers({ Authorization: `bearer ${token}` }) }).getAuthState()
|
||||||
|
.token,
|
||||||
|
).toBe(token);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,14 +51,18 @@ describe("Authenticator", async () => {
|
|||||||
});
|
});
|
||||||
const token = await auth.jwt({ sub: "test" });
|
const token = await auth.jwt({ sub: "test" });
|
||||||
|
|
||||||
const res = await auth.resolveAuthFromRequest(new Headers({
|
const res = await auth.resolveAuthFromRequest(
|
||||||
Authorization: `Bearer ${token}`,
|
new Headers({
|
||||||
}));
|
Authorization: `Bearer ${token}`,
|
||||||
expect((res as any).sub).toBe("test")
|
}),
|
||||||
|
);
|
||||||
|
expect((res as any).sub).toBe("test");
|
||||||
|
|
||||||
const res2 = await auth.resolveAuthFromRequest(new Headers({
|
const res2 = await auth.resolveAuthFromRequest(
|
||||||
Authorization: `bearer ${token}`,
|
new Headers({
|
||||||
}));
|
Authorization: `bearer ${token}`,
|
||||||
expect((res2 as any).sub).toBe("test")
|
}),
|
||||||
})
|
);
|
||||||
|
expect((res2 as any).sub).toBe("test");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe("Polymorphic", async () => {
|
|||||||
target: "media",
|
target: "media",
|
||||||
config: {
|
config: {
|
||||||
mappedBy: "image",
|
mappedBy: "image",
|
||||||
|
on_delete: "set null",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// media should not see categories
|
// media should not see categories
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { $ } from "bun";
|
|||||||
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
import { connectionTestSuite } from "data/connection/connection-test-suite";
|
||||||
import { bunTestRunner } from "adapter/bun/test";
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
|
||||||
|
const isCI = !!(process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS);
|
||||||
|
|
||||||
const credentials = {
|
const credentials = {
|
||||||
host: "localhost",
|
host: isCI ? "postgres" : "localhost",
|
||||||
port: 5430,
|
port: 5432,
|
||||||
user: "postgres",
|
user: "postgres",
|
||||||
password: "postgres",
|
password: "postgres",
|
||||||
database: "bknd",
|
database: "bknd",
|
||||||
@@ -39,6 +41,11 @@ async function isPostgresRunning() {
|
|||||||
|
|
||||||
describe("postgres", () => {
|
describe("postgres", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
if (process.env.GITEA_ACTIONS || process.env.GITHUB_ACTIONS) {
|
||||||
|
console.log("Running in CI: Skipping manual docker run");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(await isPostgresRunning())) {
|
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 $`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, 500, 20);
|
await $waitUntil("Postgres is running", isPostgresRunning, 500, 20);
|
||||||
|
|||||||
+297
-317
@@ -14,15 +14,15 @@ const clean = args.includes("--clean");
|
|||||||
|
|
||||||
// silence tsup
|
// silence tsup
|
||||||
const oldConsole = {
|
const oldConsole = {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
warn: console.warn,
|
warn: console.warn,
|
||||||
};
|
};
|
||||||
console.log = () => {};
|
console.log = () => {};
|
||||||
console.warn = () => {};
|
console.warn = () => {};
|
||||||
|
|
||||||
const define = {
|
const define = {
|
||||||
__isDev: "0",
|
__isDev: "0",
|
||||||
__version: JSON.stringify(pkg.version),
|
__version: JSON.stringify(pkg.version),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clean) {
|
if (clean) {
|
||||||
@@ -43,149 +43,146 @@ if (clean) {
|
|||||||
|
|
||||||
let types_running = false;
|
let types_running = false;
|
||||||
function buildTypes() {
|
function buildTypes() {
|
||||||
if (types_running || !types) return;
|
if (types_running || !types) return;
|
||||||
types_running = true;
|
types_running = true;
|
||||||
|
|
||||||
Bun.spawn(["bun", "build:types"], {
|
Bun.spawn(["bun", "build:types"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
oldConsole.log(c.cyan("[Types]"), c.green("built"));
|
||||||
Bun.spawn(["bun", "tsc-alias"], {
|
Bun.spawn(["bun", "tsc-alias"], {
|
||||||
stdout: "inherit",
|
stdout: "inherit",
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
oldConsole.log(c.cyan("[Types]"), c.green("aliased"));
|
||||||
types_running = false;
|
types_running = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (types && !watch) {
|
if (types && !watch) {
|
||||||
buildTypes();
|
buildTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
let watcher_timeout: any;
|
let watcher_timeout: any;
|
||||||
function delayTypes() {
|
function delayTypes() {
|
||||||
if (!watch || !types) return;
|
if (!watch || !types) return;
|
||||||
if (watcher_timeout) {
|
if (watcher_timeout) {
|
||||||
clearTimeout(watcher_timeout);
|
clearTimeout(watcher_timeout);
|
||||||
}
|
}
|
||||||
watcher_timeout = setTimeout(buildTypes, 1000);
|
watcher_timeout = setTimeout(buildTypes, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dependencies = Object.keys(pkg.dependencies);
|
const dependencies = Object.keys(pkg.dependencies);
|
||||||
|
|
||||||
// collection of always-external packages
|
// collection of always-external packages
|
||||||
const external = [
|
const external = [
|
||||||
...dependencies,
|
...dependencies,
|
||||||
"bun:test",
|
"bun:test",
|
||||||
"node:test",
|
"node:test",
|
||||||
"node:assert/strict",
|
"node:assert/strict",
|
||||||
"@libsql/client",
|
"@libsql/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
"jsonv-ts",
|
"jsonv-ts",
|
||||||
/^jsonv-ts\/.*/,
|
/^jsonv-ts\/.*/,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building backend and general API
|
* Building backend and general API
|
||||||
*/
|
*/
|
||||||
async function buildApi() {
|
async function buildApi() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
// don't use tsup's broken watch, we'll handle it ourselves
|
// don't use tsup's broken watch, we'll handle it ourselves
|
||||||
watch: false,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
entry: [
|
entry: [
|
||||||
"src/index.ts",
|
"src/index.ts",
|
||||||
"src/core/utils/index.ts",
|
"src/core/utils/index.ts",
|
||||||
"src/plugins/index.ts",
|
"src/plugins/index.ts",
|
||||||
"src/modes/index.ts",
|
"src/modes/index.ts",
|
||||||
],
|
],
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
external: [...external],
|
external: [...external],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
removeNodeProtocol: false,
|
removeNodeProtocol: false,
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
oldConsole.log(c.cyan("[API]"), c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteClient(path: string) {
|
async function rewriteClient(path: string) {
|
||||||
const bundle = await Bun.file(path).text();
|
const bundle = await Bun.file(path).text();
|
||||||
await Bun.write(
|
await Bun.write(path, '"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"));
|
||||||
path,
|
|
||||||
'"use client";\n' + bundle.replaceAll("ui/client", "bknd/client"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building UI for direct imports
|
* Building UI for direct imports
|
||||||
*/
|
*/
|
||||||
async function buildUi() {
|
async function buildUi() {
|
||||||
const base = {
|
const base = {
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch: false,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
external: [
|
external: [
|
||||||
...external,
|
...external,
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
/codemirror/,
|
/codemirror/,
|
||||||
"@xyflow/react",
|
"@xyflow/react",
|
||||||
"@mantine/core",
|
"@mantine/core",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.logLevel = "silent";
|
options.logLevel = "silent";
|
||||||
},
|
},
|
||||||
} satisfies tsup.Options;
|
} satisfies tsup.Options;
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
entry: ["src/ui/index.ts", "src/ui/main.css", "src/ui/styles.css"],
|
||||||
outDir: "dist/ui",
|
outDir: "dist/ui",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/index.js");
|
await rewriteClient("./dist/ui/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
...base,
|
...base,
|
||||||
entry: ["src/ui/client/index.ts"],
|
entry: ["src/ui/client/index.ts"],
|
||||||
outDir: "dist/ui/client",
|
outDir: "dist/ui/client",
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/client/index.js");
|
await rewriteClient("./dist/ui/client/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Client", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,195 +191,186 @@ async function buildUi() {
|
|||||||
* - ui/client is external, and after built replaced with "bknd/client"
|
* - ui/client is external, and after built replaced with "bknd/client"
|
||||||
*/
|
*/
|
||||||
async function buildUiElements() {
|
async function buildUiElements() {
|
||||||
await tsup.build({
|
await tsup.build({
|
||||||
minify,
|
minify,
|
||||||
sourcemap,
|
sourcemap,
|
||||||
watch: false,
|
watch: false,
|
||||||
define,
|
define,
|
||||||
entry: ["src/ui/elements/index.ts"],
|
entry: ["src/ui/elements/index.ts"],
|
||||||
outDir: "dist/ui/elements",
|
outDir: "dist/ui/elements",
|
||||||
external: [
|
external: [
|
||||||
"ui/client",
|
"ui/client",
|
||||||
"bknd",
|
"bknd",
|
||||||
/^bknd\/.*/,
|
/^bknd\/.*/,
|
||||||
"wouter",
|
"wouter",
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react/jsx-runtime",
|
"react/jsx-runtime",
|
||||||
"react/jsx-dev-runtime",
|
"react/jsx-dev-runtime",
|
||||||
"use-sync-external-store",
|
"use-sync-external-store",
|
||||||
],
|
],
|
||||||
metafile: true,
|
metafile: true,
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
format: ["esm"],
|
format: ["esm"],
|
||||||
splitting: false,
|
splitting: false,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
treeshake: true,
|
treeshake: true,
|
||||||
loader: {
|
loader: {
|
||||||
".svg": "dataurl",
|
".svg": "dataurl",
|
||||||
},
|
},
|
||||||
esbuildOptions: (options) => {
|
esbuildOptions: (options) => {
|
||||||
options.alias = {
|
options.alias = {
|
||||||
// not important for elements, mock to reduce bundle
|
// not important for elements, mock to reduce bundle
|
||||||
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
"tailwind-merge": "./src/ui/elements/mocks/tailwind-merge.ts",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await rewriteClient("./dist/ui/elements/index.js");
|
await rewriteClient("./dist/ui/elements/index.js");
|
||||||
delayTypes();
|
delayTypes();
|
||||||
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
oldConsole.log(c.cyan("[UI]"), "Elements", c.green("built"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Building adapters
|
* Building adapters
|
||||||
*/
|
*/
|
||||||
function baseConfig(
|
function baseConfig(adapter: string, overrides: Partial<tsup.Options> = {}): tsup.Options {
|
||||||
adapter: string,
|
return {
|
||||||
overrides: Partial<tsup.Options> = {},
|
minify,
|
||||||
): tsup.Options {
|
sourcemap,
|
||||||
return {
|
watch: false,
|
||||||
minify,
|
entry: [`src/adapter/${adapter}/index.ts`],
|
||||||
sourcemap,
|
format: ["esm"],
|
||||||
watch: false,
|
platform: "neutral",
|
||||||
entry: [`src/adapter/${adapter}/index.ts`],
|
outDir: `dist/adapter/${adapter}`,
|
||||||
format: ["esm"],
|
metafile: true,
|
||||||
platform: "neutral",
|
splitting: false,
|
||||||
outDir: `dist/adapter/${adapter}`,
|
removeNodeProtocol: false,
|
||||||
metafile: true,
|
onSuccess: async () => {
|
||||||
splitting: false,
|
delayTypes();
|
||||||
removeNodeProtocol: false,
|
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
||||||
onSuccess: async () => {
|
},
|
||||||
delayTypes();
|
...overrides,
|
||||||
oldConsole.log(c.cyan("[Adapter]"), adapter || "base", c.green("built"));
|
define: {
|
||||||
},
|
...define,
|
||||||
...overrides,
|
...overrides.define,
|
||||||
define: {
|
},
|
||||||
...define,
|
external: [
|
||||||
...overrides.define,
|
/^cloudflare*/,
|
||||||
},
|
/^@?hono.*?/,
|
||||||
external: [
|
/^(bknd|react|next|node).*?/,
|
||||||
/^cloudflare*/,
|
/.*\.(html)$/,
|
||||||
/^@?hono.*?/,
|
...external,
|
||||||
/^(bknd|react|next|node).*?/,
|
...(Array.isArray(overrides.external) ? overrides.external : []),
|
||||||
/.*\.(html)$/,
|
],
|
||||||
...external,
|
};
|
||||||
...(Array.isArray(overrides.external) ? overrides.external : []),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAdapters() {
|
async function buildAdapters() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
// base adapter handles
|
// base adapter handles
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig(""),
|
...baseConfig(""),
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
platform: "neutral",
|
platform: "neutral",
|
||||||
entry: ["src/adapter/index.ts"],
|
entry: ["src/adapter/index.ts"],
|
||||||
outDir: "dist/adapter",
|
outDir: "dist/adapter",
|
||||||
// only way to keep @vite-ignore comments
|
// only way to keep @vite-ignore comments
|
||||||
minify: false,
|
minify: false,
|
||||||
}),
|
|
||||||
|
|
||||||
// specific adatpers
|
|
||||||
tsup.build(baseConfig("react-router")),
|
|
||||||
tsup.build(
|
|
||||||
baseConfig("browser", {
|
|
||||||
external: [/^sqlocal\/?.*?/, "wouter"],
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
tsup.build(
|
// specific adatpers
|
||||||
baseConfig("bun", {
|
tsup.build(baseConfig("react-router")),
|
||||||
external: [/^bun\:.*/],
|
tsup.build(
|
||||||
|
baseConfig("browser", {
|
||||||
|
external: [/^sqlocal\/?.*?/, "wouter"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
tsup.build(
|
||||||
|
baseConfig("bun", {
|
||||||
|
external: [/^bun\:.*/],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
tsup.build(baseConfig("astro")),
|
||||||
|
tsup.build(baseConfig("aws")),
|
||||||
|
tsup.build(
|
||||||
|
baseConfig("cloudflare", {
|
||||||
|
external: ["wrangler", "node:process"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
tsup.build(
|
||||||
|
baseConfig("cloudflare/proxy", {
|
||||||
|
target: "esnext",
|
||||||
|
entry: ["src/adapter/cloudflare/proxy.ts"],
|
||||||
|
outDir: "dist/adapter/cloudflare",
|
||||||
|
metafile: false,
|
||||||
|
external: [/bknd/, "wrangler", "node:process"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
tsup.build({
|
||||||
|
...baseConfig("vite"),
|
||||||
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
tsup.build(baseConfig("astro")),
|
tsup.build({
|
||||||
tsup.build(baseConfig("aws")),
|
...baseConfig("nextjs"),
|
||||||
tsup.build(
|
platform: "node",
|
||||||
baseConfig("cloudflare", {
|
|
||||||
external: ["wrangler", "node:process"],
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
tsup.build(
|
tsup.build({
|
||||||
baseConfig("cloudflare/proxy", {
|
...baseConfig("tanstack-start"),
|
||||||
target: "esnext",
|
platform: "node",
|
||||||
entry: ["src/adapter/cloudflare/proxy.ts"],
|
|
||||||
outDir: "dist/adapter/cloudflare",
|
|
||||||
metafile: false,
|
|
||||||
external: [/bknd/, "wrangler", "node:process"],
|
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("vite"),
|
...baseConfig("sveltekit"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("nextjs"),
|
...baseConfig("nuxt"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("tanstack-start"),
|
...baseConfig("node"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("sveltekit"),
|
...baseConfig("solid-start"),
|
||||||
platform: "node",
|
platform: "node",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("nuxt"),
|
...baseConfig("sqlite/edge"),
|
||||||
platform: "node",
|
entry: ["src/adapter/sqlite/edge.ts"],
|
||||||
}),
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("node"),
|
...baseConfig("sqlite/node"),
|
||||||
platform: "node",
|
entry: ["src/adapter/sqlite/node.ts"],
|
||||||
}),
|
outDir: "dist/adapter/sqlite",
|
||||||
|
platform: "node",
|
||||||
|
metafile: false,
|
||||||
|
}),
|
||||||
|
|
||||||
tsup.build({
|
tsup.build({
|
||||||
...baseConfig("solid-start"),
|
...baseConfig("sqlite/bun"),
|
||||||
platform: "node",
|
entry: ["src/adapter/sqlite/bun.ts"],
|
||||||
}),
|
outDir: "dist/adapter/sqlite",
|
||||||
|
metafile: false,
|
||||||
tsup.build({
|
external: [/^bun\:.*/],
|
||||||
...baseConfig("sqlite/edge"),
|
}),
|
||||||
entry: ["src/adapter/sqlite/edge.ts"],
|
]);
|
||||||
outDir: "dist/adapter/sqlite",
|
|
||||||
metafile: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("sqlite/node"),
|
|
||||||
entry: ["src/adapter/sqlite/node.ts"],
|
|
||||||
outDir: "dist/adapter/sqlite",
|
|
||||||
platform: "node",
|
|
||||||
metafile: false,
|
|
||||||
}),
|
|
||||||
|
|
||||||
tsup.build({
|
|
||||||
...baseConfig("sqlite/bun"),
|
|
||||||
entry: ["src/adapter/sqlite/bun.ts"],
|
|
||||||
outDir: "dist/adapter/sqlite",
|
|
||||||
metafile: false,
|
|
||||||
external: [/^bun\:.*/],
|
|
||||||
}),
|
|
||||||
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildAll() {
|
async function buildAll() {
|
||||||
await Promise.all([
|
await Promise.all([buildApi(), buildUi(), buildUiElements(), buildAdapters()]);
|
||||||
buildApi(),
|
|
||||||
buildUi(),
|
|
||||||
buildUiElements(),
|
|
||||||
buildAdapters(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// initial build
|
// initial build
|
||||||
@@ -390,47 +378,39 @@ await buildAll();
|
|||||||
|
|
||||||
// custom watcher since tsup's watch is broken in 8.3.5+
|
// custom watcher since tsup's watch is broken in 8.3.5+
|
||||||
if (watch) {
|
if (watch) {
|
||||||
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
oldConsole.log(c.cyan("[Watch]"), "watching for changes in src/...");
|
||||||
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let isBuilding = false;
|
let isBuilding = false;
|
||||||
|
|
||||||
const rebuild = async () => {
|
const rebuild = async () => {
|
||||||
if (isBuilding) return;
|
if (isBuilding) return;
|
||||||
isBuilding = true;
|
isBuilding = true;
|
||||||
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
oldConsole.log(c.cyan("[Watch]"), "rebuilding...");
|
||||||
try {
|
try {
|
||||||
await buildAll();
|
await buildAll();
|
||||||
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
oldConsole.log(c.cyan("[Watch]"), c.green("done"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
oldConsole.warn(c.cyan("[Watch]"), c.red("build failed"), e);
|
||||||
}
|
}
|
||||||
isBuilding = false;
|
isBuilding = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedRebuild = () => {
|
const debouncedRebuild = () => {
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(rebuild, 100);
|
debounceTimer = setTimeout(rebuild, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// watch src directory recursively
|
// watch src directory recursively
|
||||||
fsWatch(
|
fsWatch(join(import.meta.dir, "src"), { recursive: true }, (event, filename) => {
|
||||||
join(import.meta.dir, "src"),
|
|
||||||
{ recursive: true },
|
|
||||||
(event, filename) => {
|
|
||||||
if (!filename) return;
|
if (!filename) return;
|
||||||
// ignore non-source files
|
// ignore non-source files
|
||||||
if (
|
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx") && !filename.endsWith(".css"))
|
||||||
!filename.endsWith(".ts") &&
|
return;
|
||||||
!filename.endsWith(".tsx") &&
|
|
||||||
!filename.endsWith(".css")
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
oldConsole.log(c.cyan("[Watch]"), c.dim(`${event}: ${filename}`));
|
||||||
debouncedRebuild();
|
debouncedRebuild();
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// keep process alive
|
// keep process alive
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -3,7 +3,10 @@ import path from "node:path";
|
|||||||
import c from "picocolors";
|
import c from "picocolors";
|
||||||
|
|
||||||
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
const basePath = new URL(import.meta.resolve("../../")).pathname.slice(0, -1);
|
||||||
type RunOptions = Omit<Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">, "stdout" | "stderr">;
|
type RunOptions = Omit<
|
||||||
|
Bun.SpawnOptions.SpawnOptions<"ignore", "pipe", "pipe">,
|
||||||
|
"stdout" | "stderr"
|
||||||
|
>;
|
||||||
|
|
||||||
async function run(
|
async function run(
|
||||||
cmd: string[] | string,
|
cmd: string[] | string,
|
||||||
|
|||||||
+39
-39
@@ -1,53 +1,53 @@
|
|||||||
const adapter = process.env.TEST_ADAPTER;
|
const adapter = process.env.TEST_ADAPTER;
|
||||||
|
|
||||||
const default_config = {
|
const default_config = {
|
||||||
media_adapter: "local",
|
media_adapter: "local",
|
||||||
base_path: "",
|
base_path: "",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const configs = {
|
const configs = {
|
||||||
cloudflare: {
|
cloudflare: {
|
||||||
media_adapter: "r2",
|
media_adapter: "r2",
|
||||||
},
|
},
|
||||||
"react-router": {
|
"react-router": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
nextjs: {
|
nextjs: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
nuxt: {
|
nuxt: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
"solid-start": {
|
"solid-start": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
astro: {
|
astro: {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
node: {
|
node: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
bun: {
|
bun: {
|
||||||
base_path: "",
|
base_path: "",
|
||||||
},
|
},
|
||||||
"tanstack-start": {
|
"tanstack-start": {
|
||||||
base_path: "/admin",
|
base_path: "/admin",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getAdapterConfig(): typeof default_config {
|
export function getAdapterConfig(): typeof default_config {
|
||||||
if (adapter) {
|
if (adapter) {
|
||||||
if (!configs[adapter]) {
|
if (!configs[adapter]) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
`Adapter "${adapter}" not found. Available adapters: ${Object.keys(configs).join(", ")}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
...default_config,
|
...default_config,
|
||||||
...configs[adapter],
|
...configs[adapter],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return default_config;
|
return default_config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ export async function getApp<Env>(
|
|||||||
* @param config - bknd configuration
|
* @param config - bknd configuration
|
||||||
* @param args - environment variables
|
* @param args - environment variables
|
||||||
*/
|
*/
|
||||||
export function serve<Env>(
|
export function serve<Env>(config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>, args: Env) {
|
||||||
config: NuxtBkndConfig<Env> = {} as NuxtBkndConfig<Env>,
|
|
||||||
args: Env,
|
|
||||||
) {
|
|
||||||
return async (request: Request) => {
|
return async (request: Request) => {
|
||||||
return (await getApp(config, args)).fetch(request);
|
return (await getApp(config, args)).fetch(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { createFrameworkApp, type FrameworkBkndConfig } from "bknd/adapter";
|
|||||||
|
|
||||||
export type TanstackStartEnv = NodeJS.ProcessEnv;
|
export type TanstackStartEnv = NodeJS.ProcessEnv;
|
||||||
|
|
||||||
export type TanstackStartConfig<Env = TanstackStartEnv> =
|
export type TanstackStartConfig<Env = TanstackStartEnv> = FrameworkBkndConfig<Env>;
|
||||||
FrameworkBkndConfig<Env>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bknd app instance
|
* Get bknd app instance
|
||||||
@@ -11,10 +10,10 @@ export type TanstackStartConfig<Env = TanstackStartEnv> =
|
|||||||
* @param args - environment variables
|
* @param args - environment variables
|
||||||
*/
|
*/
|
||||||
export async function getApp<Env = TanstackStartEnv>(
|
export async function getApp<Env = TanstackStartEnv>(
|
||||||
config: TanstackStartConfig<Env> = {},
|
config: TanstackStartConfig<Env> = {},
|
||||||
args: Env = process.env as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return await createFrameworkApp(config, args);
|
return await createFrameworkApp(config, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,11 +22,11 @@ export async function getApp<Env = TanstackStartEnv>(
|
|||||||
* @param args - environment variables
|
* @param args - environment variables
|
||||||
*/
|
*/
|
||||||
export function serve<Env = TanstackStartEnv>(
|
export function serve<Env = TanstackStartEnv>(
|
||||||
config: TanstackStartConfig<Env> = {},
|
config: TanstackStartConfig<Env> = {},
|
||||||
args: Env = process.env as Env,
|
args: Env = process.env as Env,
|
||||||
) {
|
) {
|
||||||
return async (request: Request) => {
|
return async (request: Request) => {
|
||||||
const app = await getApp(config, args);
|
const app = await getApp(config, args);
|
||||||
return app.fetch(request);
|
return app.fetch(request);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ export function shouldSkip(c: Context<ServerEnv>, skip?: (string | RegExp)[]) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const auth = (options?: {
|
export const auth = (options?: { skip?: (string | RegExp)[] }) =>
|
||||||
skip?: (string | RegExp)[];
|
|
||||||
}) =>
|
|
||||||
createMiddleware<ServerEnv>(async (c, next) => {
|
createMiddleware<ServerEnv>(async (c, next) => {
|
||||||
if (!c.get("auth")) {
|
if (!c.get("auth")) {
|
||||||
c.set("auth", {
|
c.set("auth", {
|
||||||
|
|||||||
@@ -4,52 +4,48 @@ import { plunkEmail } from "./plunk";
|
|||||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||||
|
|
||||||
describe.skipIf(ALL_TESTS)("plunk", () => {
|
describe.skipIf(ALL_TESTS)("plunk", () => {
|
||||||
it("should throw on failed", async () => {
|
it("should throw on failed", async () => {
|
||||||
const driver = plunkEmail({ apiKey: "invalid" });
|
const driver = plunkEmail({ apiKey: "invalid" });
|
||||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should send an email", async () => {
|
it("should send an email", async () => {
|
||||||
const driver = plunkEmail({
|
const driver = plunkEmail({
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
apiKey: process.env.PLUNK_API_KEY!,
|
||||||
from: undefined, // Default to what Plunk sets
|
from: undefined, // Default to what Plunk sets
|
||||||
});
|
});
|
||||||
const response = await driver.send(
|
const response = await driver.send(
|
||||||
"help@bknd.io",
|
"help@bknd.io",
|
||||||
"Test Email from Plunk",
|
"Test Email from Plunk",
|
||||||
"This is a test email",
|
"This is a test email",
|
||||||
);
|
);
|
||||||
expect(response).toBeDefined();
|
expect(response).toBeDefined();
|
||||||
expect(response.success).toBe(true);
|
expect(response.success).toBe(true);
|
||||||
expect(response.emails).toBeDefined();
|
expect(response.emails).toBeDefined();
|
||||||
expect(response.timestamp).toBeDefined();
|
expect(response.timestamp).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should send HTML email", async () => {
|
it("should send HTML email", async () => {
|
||||||
const driver = plunkEmail({
|
const driver = plunkEmail({
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
apiKey: process.env.PLUNK_API_KEY!,
|
||||||
from: undefined,
|
from: undefined,
|
||||||
});
|
});
|
||||||
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
const htmlBody = "<h1>Test Email</h1><p>This is a test email</p>";
|
||||||
const response = await driver.send(
|
const response = await driver.send("help@bknd.io", "HTML Test", htmlBody);
|
||||||
"help@bknd.io",
|
expect(response).toBeDefined();
|
||||||
"HTML Test",
|
expect(response.success).toBe(true);
|
||||||
htmlBody,
|
});
|
||||||
);
|
|
||||||
expect(response).toBeDefined();
|
|
||||||
expect(response.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should send with text and html", async () => {
|
it("should send with text and html", async () => {
|
||||||
const driver = plunkEmail({
|
const driver = plunkEmail({
|
||||||
apiKey: process.env.PLUNK_API_KEY!,
|
apiKey: process.env.PLUNK_API_KEY!,
|
||||||
from: undefined,
|
from: undefined,
|
||||||
});
|
});
|
||||||
const response = await driver.send("test@example.com", "Test Email", {
|
const response = await driver.send("test@example.com", "Test Email", {
|
||||||
text: "help@bknd.io",
|
text: "help@bknd.io",
|
||||||
html: "<p>This is HTML</p>",
|
html: "<p>This is HTML</p>",
|
||||||
});
|
});
|
||||||
expect(response).toBeDefined();
|
expect(response).toBeDefined();
|
||||||
expect(response.success).toBe(true);
|
expect(response.success).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
import type { IEmailDriver } from "./index";
|
import type { IEmailDriver } from "./index";
|
||||||
|
|
||||||
export type PlunkEmailOptions = {
|
export type PlunkEmailOptions = {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlunkEmailSendOptions = {
|
export type PlunkEmailSendOptions = {
|
||||||
subscribed?: boolean;
|
subscribed?: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
reply?: string;
|
reply?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlunkEmailResponse = {
|
export type PlunkEmailResponse = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
emails: Array<{
|
emails: Array<{
|
||||||
contact: {
|
contact: {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
};
|
};
|
||||||
email: string;
|
email: string;
|
||||||
}>;
|
}>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const plunkEmail = (
|
export const plunkEmail = (
|
||||||
config: PlunkEmailOptions,
|
config: PlunkEmailOptions,
|
||||||
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
): IEmailDriver<PlunkEmailResponse, PlunkEmailSendOptions> => {
|
||||||
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
const host = config.host ?? "https://api.useplunk.com/v1/send";
|
||||||
const from = config.from;
|
const from = config.from;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send: async (
|
send: async (
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
body: string | { text: string; html: string },
|
body: string | { text: string; html: string },
|
||||||
options?: PlunkEmailSendOptions,
|
options?: PlunkEmailSendOptions,
|
||||||
) => {
|
) => {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof body === "string") {
|
if (typeof body === "string") {
|
||||||
payload.body = body;
|
payload.body = body;
|
||||||
} else {
|
} else {
|
||||||
payload.body = body.html;
|
payload.body = body.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(host, {
|
const res = await fetch(host, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${config.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...payload, ...options }),
|
body: JSON.stringify({ ...payload, ...options }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Plunk API error: ${await res.text()}`);
|
throw new Error(`Plunk API error: ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await res.json()) as PlunkEmailResponse;
|
return (await res.json()) as PlunkEmailResponse;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import { v4, v7, validate, version as uuidVersion } from "uuid";
|
|||||||
|
|
||||||
// generates v4
|
// generates v4
|
||||||
export function uuid(): string {
|
export function uuid(): string {
|
||||||
return v4();
|
return v4();
|
||||||
}
|
}
|
||||||
|
|
||||||
// generates v7
|
// generates v7
|
||||||
export function uuidv7(): string {
|
export function uuidv7(): string {
|
||||||
return v7();
|
return v7();
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate uuid
|
// validate uuid
|
||||||
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
|
export function uuidValidate(uuid: string, version: 4 | 7): boolean {
|
||||||
return validate(uuid) && uuidVersion(uuid) === version;
|
return validate(uuid) && uuidVersion(uuid) === version;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Kysely, PostgresDialect, type PostgresDialectConfig as KyselyPostgresDialectConfig } from "kysely";
|
import {
|
||||||
|
Kysely,
|
||||||
|
PostgresDialect,
|
||||||
|
type PostgresDialectConfig as KyselyPostgresDialectConfig,
|
||||||
|
} from "kysely";
|
||||||
import { PostgresIntrospector } from "./PostgresIntrospector";
|
import { PostgresIntrospector } from "./PostgresIntrospector";
|
||||||
import { PostgresConnection, plugins } from "./PostgresConnection";
|
import { PostgresConnection, plugins } from "./PostgresConnection";
|
||||||
import { customIntrospector } from "../Connection";
|
import { customIntrospector } from "../Connection";
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export class PostgresJsConnection extends PostgresConnection<PostgresJSDialectCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postgresJs(
|
export function postgresJs(config: PostgresJSDialectConfig): PostgresJsConnection {
|
||||||
config: PostgresJSDialectConfig,
|
|
||||||
): PostgresJsConnection {
|
|
||||||
return new PostgresJsConnection(config);
|
return new PostgresJsConnection(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,10 @@ export class Entity<
|
|||||||
getFields({
|
getFields({
|
||||||
virtual = false,
|
virtual = false,
|
||||||
primary = true,
|
primary = true,
|
||||||
}: { virtual?: boolean; primary?: boolean } = {}): Field[] {
|
}: {
|
||||||
|
virtual?: boolean;
|
||||||
|
primary?: boolean;
|
||||||
|
} = {}): Field[] {
|
||||||
return this.fields.filter((f) => {
|
return this.fields.filter((f) => {
|
||||||
if (!virtual && f.isVirtual()) return false;
|
if (!virtual && f.isVirtual()) return false;
|
||||||
if (!primary && f instanceof PrimaryField) return false;
|
if (!primary && f instanceof PrimaryField) return false;
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export class EntityManager<TBD extends object = DefaultDB> {
|
|||||||
emgr?: EventManager<any>,
|
emgr?: EventManager<any>,
|
||||||
) {
|
) {
|
||||||
// add entities & relations
|
// add entities & relations
|
||||||
entities.forEach((entity) => this.addEntity(entity));
|
entities.forEach((entity) => void this.addEntity(entity));
|
||||||
relations.forEach((relation) => this.addRelation(relation));
|
relations.forEach((relation) => void this.addRelation(relation));
|
||||||
indices.forEach((index) => this.addIndex(index));
|
indices.forEach((index) => void this.addIndex(index));
|
||||||
|
|
||||||
if (!Connection.isConnection(connection)) {
|
if (!Connection.isConnection(connection)) {
|
||||||
throw new UnableToConnectException("");
|
throw new UnableToConnectException("");
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
RelationHelper,
|
RelationHelper,
|
||||||
} from "../relations";
|
} from "../relations";
|
||||||
import type { RepoQuery } from "../server/query";
|
import type { RepoQuery } from "../server/query";
|
||||||
import type { RelationType } from "./relation-types";
|
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationType } from "./relation-types";
|
||||||
|
|
||||||
const directions = ["source", "target"] as const;
|
const directions = ["source", "target"] as const;
|
||||||
export type TDirection = (typeof directions)[number];
|
export type TDirection = (typeof directions)[number];
|
||||||
@@ -35,6 +35,7 @@ export abstract class EntityRelation<
|
|||||||
mappedBy: s.string().optional(),
|
mappedBy: s.string().optional(),
|
||||||
inversedBy: s.string().optional(),
|
inversedBy: s.string().optional(),
|
||||||
required: s.boolean().optional(),
|
required: s.boolean().optional(),
|
||||||
|
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// don't make protected, App requires it to instantiatable
|
// don't make protected, App requires it to instantiatable
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { EntityRelation, type KyselyQueryBuilder } from "./EntityRelation";
|
|||||||
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
import { RelationField, type RelationFieldBaseConfig } from "./RelationField";
|
||||||
import type { MutationInstructionResponse } from "./RelationMutator";
|
import type { MutationInstructionResponse } from "./RelationMutator";
|
||||||
import { type RelationType, RelationTypes } from "./relation-types";
|
import { type RelationType, RelationCascades, RelationTypes } from "./relation-types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Source entity receives the mapping field
|
* Source entity receives the mapping field
|
||||||
@@ -31,6 +31,7 @@ export class ManyToOneRelation extends EntityRelation<typeof ManyToOneRelation.s
|
|||||||
fieldConfig: s
|
fieldConfig: s
|
||||||
.object({
|
.object({
|
||||||
label: s.string(),
|
label: s.string(),
|
||||||
|
on_delete: s.string({ enum: RelationCascades }).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
...EntityRelation.schema.properties,
|
...EntityRelation.schema.properties,
|
||||||
|
|||||||
@@ -4,20 +4,22 @@ import type { EntityRelation } from "./EntityRelation";
|
|||||||
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
import type { EntityRelationAnchor } from "./EntityRelationAnchor";
|
||||||
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
import type { TFieldTSType } from "data/entities/EntityTypescript";
|
||||||
import { s } from "bknd/utils";
|
import { s } from "bknd/utils";
|
||||||
|
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
|
||||||
const CASCADES = ["cascade", "set null", "set default", "restrict", "no action"] as const;
|
|
||||||
|
|
||||||
export const relationFieldConfigSchema = s.strictObject({
|
export const relationFieldConfigSchema = s.strictObject({
|
||||||
reference: s.string(),
|
reference: s.string(),
|
||||||
target: s.string(), // @todo: potentially has to be an instance!
|
target: s.string(), // @todo: potentially has to be an instance!
|
||||||
target_field: s.string({ default: "id" }).optional(),
|
target_field: s.string({ default: "id" }).optional(),
|
||||||
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
|
target_field_type: s.string({ enum: ["text", "integer"], default: "integer" }).optional(),
|
||||||
on_delete: s.string({ enum: CASCADES, default: "set null" }).optional(),
|
on_delete: s.string({ enum: RelationCascades, default: DEFAULT_RELATION_CASCADE }).optional(),
|
||||||
...baseFieldConfigSchema.properties,
|
...baseFieldConfigSchema.properties,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
|
export type RelationFieldConfig = s.Static<typeof relationFieldConfigSchema>;
|
||||||
export type RelationFieldBaseConfig = { label?: string };
|
export type RelationFieldBaseConfig = {
|
||||||
|
label?: string;
|
||||||
|
on_delete?: RelationCascade;
|
||||||
|
};
|
||||||
|
|
||||||
export class RelationField extends Field<RelationFieldConfig> {
|
export class RelationField extends Field<RelationFieldConfig> {
|
||||||
override readonly type = "relation";
|
override readonly type = "relation";
|
||||||
@@ -39,6 +41,8 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
return new RelationField(name, {
|
return new RelationField(name, {
|
||||||
...config,
|
...config,
|
||||||
required: relation.required,
|
required: relation.required,
|
||||||
|
// the relation owns the referential action, the field only materializes it
|
||||||
|
on_delete: config?.on_delete ?? relation.config.on_delete,
|
||||||
reference: target.reference,
|
reference: target.reference,
|
||||||
target: target.entity.name,
|
target: target.entity.name,
|
||||||
target_field: target.entity.getPrimaryField().name,
|
target_field: target.entity.getPrimaryField().name,
|
||||||
@@ -50,6 +54,10 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
return this.config.reference;
|
return this.config.reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onDelete(): RelationCascade | undefined {
|
||||||
|
return this.config.on_delete;
|
||||||
|
}
|
||||||
|
|
||||||
target() {
|
target() {
|
||||||
return this.config.target;
|
return this.config.target;
|
||||||
}
|
}
|
||||||
@@ -63,7 +71,7 @@ export class RelationField extends Field<RelationFieldConfig> {
|
|||||||
...super.schema()!,
|
...super.schema()!,
|
||||||
type: this.config.target_field_type ?? "integer",
|
type: this.config.target_field_type ?? "integer",
|
||||||
references: `${this.config.target}.${this.config.target_field}`,
|
references: `${this.config.target}.${this.config.target_field}`,
|
||||||
onDelete: this.config.on_delete ?? "set null",
|
onDelete: this.config.on_delete ?? DEFAULT_RELATION_CASCADE,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ManyToOneRelation, type ManyToOneRelationConfig } from "./ManyToOneRela
|
|||||||
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
|
import { OneToOneRelation, type OneToOneRelationConfig } from "./OneToOneRelation";
|
||||||
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
import { PolymorphicRelation, type PolymorphicRelationConfig } from "./PolymorphicRelation";
|
||||||
import { type RelationType, RelationTypes } from "./relation-types";
|
import { type RelationType, RelationTypes } from "./relation-types";
|
||||||
|
import { DEFAULT_RELATION_CASCADE, RelationCascades, type RelationCascade } from "./relation-types";
|
||||||
|
|
||||||
export * from "./EntityRelation";
|
export * from "./EntityRelation";
|
||||||
export * from "./EntityRelationAnchor";
|
export * from "./EntityRelationAnchor";
|
||||||
@@ -28,6 +29,9 @@ export {
|
|||||||
type PolymorphicRelationConfig,
|
type PolymorphicRelationConfig,
|
||||||
RelationTypes,
|
RelationTypes,
|
||||||
type RelationType,
|
type RelationType,
|
||||||
|
RelationCascades,
|
||||||
|
type RelationCascade,
|
||||||
|
DEFAULT_RELATION_CASCADE,
|
||||||
// field
|
// field
|
||||||
RelationField,
|
RelationField,
|
||||||
relationFieldConfigSchema,
|
relationFieldConfigSchema,
|
||||||
|
|||||||
@@ -5,3 +5,13 @@ export const RelationTypes = {
|
|||||||
Polymorphic: "poly",
|
Polymorphic: "poly",
|
||||||
} as const;
|
} as const;
|
||||||
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
|
export type RelationType = (typeof RelationTypes)[keyof typeof RelationTypes];
|
||||||
|
|
||||||
|
export const RelationCascades = [
|
||||||
|
"cascade",
|
||||||
|
"set null",
|
||||||
|
"set default",
|
||||||
|
"restrict",
|
||||||
|
"no action",
|
||||||
|
] as const;
|
||||||
|
export type RelationCascade = (typeof RelationCascades)[number];
|
||||||
|
export const DEFAULT_RELATION_CASCADE: RelationCascade = "set null";
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export type BkndAdminConfig = {
|
|||||||
*/
|
*/
|
||||||
basepath?: string;
|
basepath?: string;
|
||||||
/**
|
/**
|
||||||
* Sub-path for the Admin UI within the base path
|
* Sub-path for the Admin UI within the base path
|
||||||
* @default ``
|
* @default ``
|
||||||
*/
|
*/
|
||||||
admin_basepath?: string;
|
admin_basepath?: string;
|
||||||
/**
|
/**
|
||||||
* Path to return to when clicking the logo
|
* Path to return to when clicking the logo
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export {
|
|||||||
type ClientProviderProps,
|
type ClientProviderProps,
|
||||||
useApi,
|
useApi,
|
||||||
useBaseUrl,
|
useBaseUrl,
|
||||||
useClientContext
|
useClientContext,
|
||||||
} from "./ClientProvider";
|
} from "./ClientProvider";
|
||||||
|
|
||||||
export * from "./api/use-api";
|
export * from "./api/use-api";
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export class AppReduced {
|
|||||||
protected appJson: AppType,
|
protected appJson: AppType,
|
||||||
protected _options: BkndAdminProps["config"] = {},
|
protected _options: BkndAdminProps["config"] = {},
|
||||||
) {
|
) {
|
||||||
|
|
||||||
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
this._entities = Object.entries(this.appJson.data.entities ?? {}).map(([name, entity]) => {
|
||||||
return constructEntity(name, entity);
|
return constructEntity(name, entity);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ export function Logo({
|
|||||||
scale = 0.2,
|
scale = 0.2,
|
||||||
fill,
|
fill,
|
||||||
...props
|
...props
|
||||||
}: { scale?: number; fill?: string; theme?: string }) {
|
}: {
|
||||||
|
scale?: number;
|
||||||
|
fill?: string;
|
||||||
|
theme?: string;
|
||||||
|
}) {
|
||||||
const t = useTheme();
|
const t = useTheme();
|
||||||
const theme = props.theme ?? t.theme;
|
const theme = props.theme ?? t.theme;
|
||||||
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
const svgFill = fill ? fill : theme === "light" ? "black" : "white";
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ const ArrayAdd = ({
|
|||||||
schema,
|
schema,
|
||||||
path: _path,
|
path: _path,
|
||||||
label = "Add",
|
label = "Add",
|
||||||
}: { schema: JsonSchema; path: string; label?: string }) => {
|
}: {
|
||||||
|
schema: JsonSchema;
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
const {
|
const {
|
||||||
setValue,
|
setValue,
|
||||||
value: { currentIndex },
|
value: { currentIndex },
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ export function Step({
|
|||||||
path = [],
|
path = [],
|
||||||
id,
|
id,
|
||||||
...rest
|
...rest
|
||||||
}: { children: React.ReactNode; disabled?: boolean; id: string; path?: string[] }) {
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
id: string;
|
||||||
|
path?: string[];
|
||||||
|
}) {
|
||||||
return <div {...rest}>{children}</div>;
|
return <div {...rest}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ export function checkMaxReached({
|
|||||||
current = 0,
|
current = 0,
|
||||||
overwrite,
|
overwrite,
|
||||||
added,
|
added,
|
||||||
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) {
|
}: {
|
||||||
|
maxItems?: number;
|
||||||
|
current?: number;
|
||||||
|
overwrite?: boolean;
|
||||||
|
added: number;
|
||||||
|
}) {
|
||||||
if (!maxItems) {
|
if (!maxItems) {
|
||||||
return {
|
return {
|
||||||
reject: false,
|
reject: false,
|
||||||
|
|||||||
@@ -101,7 +101,12 @@ export function useNavigate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _url = options?.absolute ? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(/\/+/g, "/") : url;
|
const _url = options?.absolute
|
||||||
|
? `~/${app.options.basepath}/${app.options.admin_basepath}${url}`.replace(
|
||||||
|
/\/+/g,
|
||||||
|
"/",
|
||||||
|
)
|
||||||
|
: url;
|
||||||
const state = {
|
const state = {
|
||||||
...options?.state,
|
...options?.state,
|
||||||
referrer: location,
|
referrer: location,
|
||||||
|
|||||||
@@ -283,7 +283,10 @@ function EntityJsonFormField({
|
|||||||
fieldApi,
|
fieldApi,
|
||||||
field,
|
field,
|
||||||
...props
|
...props
|
||||||
}: { fieldApi: TFieldApi; field: JsonField }) {
|
}: {
|
||||||
|
fieldApi: TFieldApi;
|
||||||
|
field: JsonField;
|
||||||
|
}) {
|
||||||
const [error, setError] = useState<any>(null);
|
const [error, setError] = useState<any>(null);
|
||||||
const handleUpdate = useEvent((value: any) => {
|
const handleUpdate = useEvent((value: any) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -312,7 +315,10 @@ function EntityEnumFormField({
|
|||||||
fieldApi,
|
fieldApi,
|
||||||
field,
|
field,
|
||||||
...props
|
...props
|
||||||
}: { fieldApi: TFieldApi; field: EnumField }) {
|
}: {
|
||||||
|
fieldApi: TFieldApi;
|
||||||
|
field: EnumField;
|
||||||
|
}) {
|
||||||
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleUpdate = useEvent((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
fieldApi.handleChange(e.target.value);
|
fieldApi.handleChange(e.target.value);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { Switch, TextInput } from "@mantine/core";
|
import { Switch, TextInput } from "@mantine/core";
|
||||||
import { IconDatabase } from "@tabler/icons-react";
|
import { IconDatabase } from "@tabler/icons-react";
|
||||||
import { ManyToOneRelation, type RelationType, RelationTypes } from "data/relations";
|
import {
|
||||||
|
DEFAULT_RELATION_CASCADE,
|
||||||
|
ManyToOneRelation,
|
||||||
|
type RelationType,
|
||||||
|
RelationCascades,
|
||||||
|
RelationTypes,
|
||||||
|
} from "data/relations";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
import { type Control, type FieldValues, type UseFormRegister, useForm } from "react-hook-form";
|
||||||
import { TbRefresh } from "react-icons/tb";
|
import { TbRefresh } from "react-icons/tb";
|
||||||
@@ -46,7 +52,9 @@ const schema = s.strictObject({
|
|||||||
type: s.string({ enum: Relations.map((r) => r.type) }),
|
type: s.string({ enum: Relations.map((r) => r.type) }),
|
||||||
source: stringIdentifier,
|
source: stringIdentifier,
|
||||||
target: stringIdentifier,
|
target: stringIdentifier,
|
||||||
config: s.object({}),
|
config: s.object({
|
||||||
|
on_delete: s.string({ enum: RelationCascades }).optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ComponentCtx<T extends FieldValues = FieldValues> = {
|
type ComponentCtx<T extends FieldValues = FieldValues> = {
|
||||||
@@ -186,6 +194,25 @@ const Callout = ({ children }: { children: ReactNode }) => (
|
|||||||
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
|
<div className="bg-primary/5 py-4 px-5 rounded-lg mt-10">{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
function OnDelete({
|
||||||
|
control,
|
||||||
|
label = "On target delete",
|
||||||
|
}: {
|
||||||
|
control: Control<any>;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MantineSelect
|
||||||
|
control={control}
|
||||||
|
name="config.on_delete"
|
||||||
|
label={label}
|
||||||
|
allowDeselect={false}
|
||||||
|
defaultValue={DEFAULT_RELATION_CASCADE}
|
||||||
|
data={RelationCascades.map((cascade) => ({ value: cascade, label: cascade }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
|
function ManyToOne({ register, control, data: { source, target, config } }: ComponentCtx) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -209,6 +236,7 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
|
|||||||
control={control}
|
control={control}
|
||||||
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
|
placeholder={String(ManyToOneRelation.DEFAULTS.with_limit)}
|
||||||
/>
|
/>
|
||||||
|
<OnDelete control={control} />
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div />
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -222,30 +250,32 @@ function ManyToOne({ register, control, data: { source, target, config } }: Comp
|
|||||||
</div>
|
</div>
|
||||||
{source && target && config && (
|
{source && target && config && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<>
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<IconDatabase className="size-4" />
|
||||||
<IconDatabase className="size-4" />
|
{source}.{config.mappedBy || target}_id {"→"} {target}
|
||||||
{source}.{config.mappedBy || target}_id {"→"} {target}
|
</pre>
|
||||||
</pre>
|
<p>
|
||||||
|
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
|
||||||
|
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||||
|
<i>{config.required ? "required" : "optional"}</i>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
|
||||||
|
<i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
||||||
|
</p>
|
||||||
|
{config.sourceCardinality ? (
|
||||||
<p>
|
<p>
|
||||||
Many <Pre>{source}</Pre> will each have one reference to <Pre>{target}</Pre>.
|
<Pre>{target}</Pre> should not have more than{" "}
|
||||||
|
<Pre>{config.sourceCardinality}</Pre> referencing entr
|
||||||
|
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
) : null}
|
||||||
A property <Pre>{config.mappedBy || target}_id</Pre> will be added to{" "}
|
|
||||||
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
|
||||||
<i>{config.required ? "required" : "optional"}</i>.
|
|
||||||
</p>
|
|
||||||
{config.sourceCardinality ? (
|
|
||||||
<p>
|
|
||||||
<Pre>{target}</Pre> should not have more than{" "}
|
|
||||||
<Pre>{config.sourceCardinality}</Pre> referencing entr
|
|
||||||
{config.sourceCardinality === 1 ? "y" : "ies"} to <Pre>{source}</Pre>.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -258,7 +288,7 @@ function OneToOne({
|
|||||||
data: {
|
data: {
|
||||||
source,
|
source,
|
||||||
target,
|
target,
|
||||||
config: { mappedBy, required },
|
config: { mappedBy, required, on_delete },
|
||||||
},
|
},
|
||||||
}: ComponentCtx) {
|
}: ComponentCtx) {
|
||||||
return (
|
return (
|
||||||
@@ -271,6 +301,7 @@ function OneToOne({
|
|||||||
placeholder={target}
|
placeholder={target}
|
||||||
/>
|
/>
|
||||||
<Switch label="Required" {...register("config.required")} />
|
<Switch label="Required" {...register("config.required")} />
|
||||||
|
<OnDelete control={control} />
|
||||||
</div>
|
</div>
|
||||||
<div />
|
<div />
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -283,24 +314,26 @@ function OneToOne({
|
|||||||
</div>
|
</div>
|
||||||
{source && target && (
|
{source && target && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<>
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<IconDatabase className="size-4" />
|
||||||
<IconDatabase className="size-4" />
|
{source}.{mappedBy || target}_id {"↔"} {target}
|
||||||
{source}.{mappedBy || target}_id {"↔"} {target}
|
</pre>
|
||||||
</pre>
|
<p>
|
||||||
<p>
|
A single entry of <Pre>{source}</Pre> will have a reference to <Pre>{target}</Pre>
|
||||||
A single entry of <Pre>{source}</Pre> will have a reference to{" "}
|
.
|
||||||
<Pre>{target}</Pre>.
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p>
|
A property <Pre>{mappedBy || target}_id</Pre> will be added to <Pre>{source}</Pre>{" "}
|
||||||
A property <Pre>{mappedBy || target}_id</Pre> will be added to{" "}
|
(which references <Pre>{target}</Pre>).
|
||||||
<Pre>{source}</Pre> (which references <Pre>{target}</Pre>).
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p>
|
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
||||||
When creating <Pre>{source}</Pre>, a reference to <Pre>{target}</Pre> is{" "}
|
<i>{required ? "required" : "optional"}</i>.
|
||||||
<i>{required ? "required" : "optional"}</i>.
|
</p>
|
||||||
</p>
|
<p>
|
||||||
</>
|
If a <Pre>{target}</Pre> entry is deleted, the reference is{" "}
|
||||||
|
<i>{on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
||||||
|
</p>
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -334,6 +367,7 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
|||||||
{...register("config.connectionTableMappedName")}
|
{...register("config.connectionTableMappedName")}
|
||||||
placeholder={table}
|
placeholder={table}
|
||||||
/>
|
/>
|
||||||
|
<OnDelete control={control} label="On delete (both references)" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/*<TextInput
|
{/*<TextInput
|
||||||
@@ -345,18 +379,20 @@ function ManyToMany({ register, control, data: { source, target, config } }: Com
|
|||||||
</div>
|
</div>
|
||||||
{source && target && (
|
{source && target && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<>
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<IconDatabase className="size-4" />
|
||||||
<IconDatabase className="size-4" />
|
{source} {"→"} {table} {"←"} {target}
|
||||||
{source} {"→"} {table} {"←"} {target}
|
</pre>
|
||||||
</pre>
|
<p>
|
||||||
<p>
|
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
||||||
Many <Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p>
|
A connection table <Pre>{table}</Pre> will be created to store the relations.
|
||||||
A connection table <Pre>{table}</Pre> will be created to store the relations.
|
</p>
|
||||||
</p>
|
<p>
|
||||||
</>
|
If an entry of <Pre>{source}</Pre> or <Pre>{target}</Pre> is deleted, the
|
||||||
|
connection is <i>{config.on_delete ?? DEFAULT_RELATION_CASCADE}</i>.
|
||||||
|
</p>
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -391,26 +427,24 @@ function Polymorphic({ register, control, data: { type, source, target, config }
|
|||||||
</div>
|
</div>
|
||||||
{source && target && (
|
{source && target && (
|
||||||
<Callout>
|
<Callout>
|
||||||
<>
|
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
||||||
<pre className="mb-2 opacity-70 flex flex-row items-center gap-2">
|
<IconDatabase className="size-4" />
|
||||||
<IconDatabase className="size-4" />
|
{source} {"←"} {target}
|
||||||
{source} {"←"} {target}
|
</pre>
|
||||||
</pre>
|
<p>
|
||||||
|
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
|
||||||
|
<Pre>entity_id</Pre> to make the (polymorphic) reference.
|
||||||
|
</p>
|
||||||
|
{config.targetCardinality ? (
|
||||||
<p>
|
<p>
|
||||||
<Pre>{source}</Pre> will have many <Pre>{target}</Pre>.
|
<Pre>{source}</Pre> should not have more than{" "}
|
||||||
|
<Pre>{config.targetCardinality}</Pre> reference
|
||||||
|
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
) : null}
|
||||||
<Pre>{target}</Pre> will get additional properties <Pre>reference</Pre> and{" "}
|
|
||||||
<Pre>entity_id</Pre> to make the (polymorphic) reference.
|
|
||||||
</p>
|
|
||||||
{config.targetCardinality ? (
|
|
||||||
<p>
|
|
||||||
<Pre>{source}</Pre> should not have more than{" "}
|
|
||||||
<Pre>{config.targetCardinality}</Pre> reference
|
|
||||||
{config.targetCardinality === 1 ? "" : "s"} to <Pre>{target}</Pre>.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -110,7 +110,12 @@ const EntityLinkList = ({
|
|||||||
title,
|
title,
|
||||||
context,
|
context,
|
||||||
suggestCreate = false,
|
suggestCreate = false,
|
||||||
}: { entities: Entity[]; title?: string; context: "data" | "schema"; suggestCreate?: boolean }) => {
|
}: {
|
||||||
|
entities: Entity[];
|
||||||
|
title?: string;
|
||||||
|
context: "data" | "schema";
|
||||||
|
suggestCreate?: boolean;
|
||||||
|
}) => {
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
const { readonly } = useBknd();
|
const { readonly } = useBknd();
|
||||||
const navigate = useRouteNavigate();
|
const navigate = useRouteNavigate();
|
||||||
@@ -195,7 +200,11 @@ const EntityContextMenu = ({
|
|||||||
entity,
|
entity,
|
||||||
children,
|
children,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: { entity: Entity; children: DropdownClickableChild; enabled?: boolean }) => {
|
}: {
|
||||||
|
entity: Entity;
|
||||||
|
children: DropdownClickableChild;
|
||||||
|
enabled?: boolean;
|
||||||
|
}) => {
|
||||||
if (!enabled) return children;
|
if (!enabled) return children;
|
||||||
const [navigate] = useNavigate();
|
const [navigate] = useNavigate();
|
||||||
const { $data } = useBkndData();
|
const { $data } = useBkndData();
|
||||||
|
|||||||
@@ -66,7 +66,10 @@ const relationAnyOfValues = {
|
|||||||
export const DataSettings = ({
|
export const DataSettings = ({
|
||||||
schema,
|
schema,
|
||||||
config,
|
config,
|
||||||
}: { schema: ModuleSchemas["data"]; config: ModuleConfigs["data"] }) => {
|
}: {
|
||||||
|
schema: ModuleSchemas["data"];
|
||||||
|
config: ModuleConfigs["data"];
|
||||||
|
}) => {
|
||||||
const { app, readonly } = useBknd();
|
const { app, readonly } = useBknd();
|
||||||
const prefix = app.getAbsolutePath("settings");
|
const prefix = app.getAbsolutePath("settings");
|
||||||
const entities = Object.keys(config.entities ?? {});
|
const entities = Object.keys(config.entities ?? {});
|
||||||
|
|||||||
@@ -69,11 +69,7 @@ function CustomUserAvatarDropzone() {
|
|||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
actions: { openFileInput },
|
actions: { openFileInput },
|
||||||
} = Media.useDropzone();
|
} = Media.useDropzone();
|
||||||
const {
|
const { isOver, isOverAccepted, files: [file] = [] } = Media.useDropzoneState();
|
||||||
isOver,
|
|
||||||
isOverAccepted,
|
|
||||||
files: [file] = [],
|
|
||||||
} = Media.useDropzoneState();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -98,7 +98,9 @@ input[disabled]::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flex-animate {
|
.flex-animate {
|
||||||
transition: flex-grow 0.2s ease, background-color 0.2s ease;
|
transition:
|
||||||
|
flex-grow 0.2s ease,
|
||||||
|
background-color 0.2s ease;
|
||||||
}
|
}
|
||||||
.flex-initial {
|
.flex-initial {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
declare module '*.css';
|
declare module "*.css";
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export type NextjsBkndConfig<Env = NextjsEnv> = FrameworkBkndConfig<Env> & {
|
|||||||
|
|
||||||
## Serve the API
|
## Serve the API
|
||||||
|
|
||||||
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configurationfrom the `bknd.config.ts` file:
|
Create a helper file to instantiate the bknd instance and retrieve the API, importing the configuration from the `bknd.config.ts` file:
|
||||||
|
|
||||||
```ts title="src/bknd.ts"
|
```ts title="src/bknd.ts"
|
||||||
import {
|
import {
|
||||||
@@ -127,31 +127,80 @@ export const DELETE = handler;
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Enabling the Admin UI
|
## Enabling the Admin UI
|
||||||
|
Create a file at `admin/[[...admin]]/page.client.tsx`:
|
||||||
|
|
||||||
|
``` tsx title="admin/[[...admin]]/page.client.tsx"
|
||||||
|
"use client";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
export const Admin = dynamic(async () => (await import("bknd/ui")).Admin, {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
<Callout type="info">
|
||||||
|
We are using [Colocation](https://nextjs.org/docs/app/getting-started/project-structure#colocation) to prevent server-side hydration error, and the dynamic function to load Admin component only on client side.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
|
||||||
Create a page at `admin/[[...admin]]/page.tsx`:
|
Create a page at `admin/[[...admin]]/page.tsx`:
|
||||||
|
|
||||||
```tsx title="admin/[[...admin]]/page.tsx"
|
```tsx title="admin/[[...admin]]/page.tsx"
|
||||||
import { Admin } from "bknd/ui";
|
|
||||||
import { getApi } from "@/bknd";
|
import { getApi } from "@/bknd";
|
||||||
import "bknd/dist/styles.css";
|
import "bknd/dist/styles.css";
|
||||||
|
import { Admin } from "./page.client";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
// make sure to verify auth using headers
|
// make sure to verify auth using headers
|
||||||
const api = await getApi({ verify: true });
|
const api = await getApi({ verify: true });
|
||||||
|
|
||||||
|
// early return if not logged in
|
||||||
|
if (!api.getUser()) {
|
||||||
|
return redirect("/unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Admin
|
<Suspense>
|
||||||
withProvider={{ user: api.getUser() }}
|
<Admin
|
||||||
config={{
|
withProvider={{ user: api.getUser() }}
|
||||||
basepath: "/admin",
|
config={{
|
||||||
logo_return_path: "/../",
|
basepath: "/admin",
|
||||||
theme: "system",
|
logo_return_path: "/../",
|
||||||
}}
|
theme: "system",
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## React SDK configuration
|
||||||
|
|
||||||
|
To use queries and mutation on client side, bknd provides first class support for it with it's [React SDK](/usage/react)
|
||||||
|
to set it up with Next.js add the `ClientProvider` to your root layout
|
||||||
|
|
||||||
|
|
||||||
|
```tsx title="app/layout.tsx"
|
||||||
|
import { ClientProvider } from "bknd/client";
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<ClientProvider>
|
||||||
|
{children}
|
||||||
|
</ClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Example usage of the API
|
## Example usage of the API
|
||||||
|
|
||||||
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
|
You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components:
|
||||||
@@ -172,3 +221,26 @@ export default async function Home() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Note for Production
|
||||||
|
If your admin component is not rendering and has a error like `Identifier 'b' has already been declared`, you will need to add these lines to your `next.config.(mjs|ts)` file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// ...
|
||||||
|
// for turbopack
|
||||||
|
experimental: {
|
||||||
|
turbopackMinify: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// for webpack
|
||||||
|
// webpack: (config) => {
|
||||||
|
// config.optimization.minimize = false;
|
||||||
|
// return config;
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user