diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6a08855..224f18f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -20,7 +20,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
- bun-version: "1.3.1"
+ bun-version: "1.3.2"
- name: Install dependencies
working-directory: ./app
diff --git a/app/.env.example b/app/.env.example
index a70d8e7..463c8a7 100644
--- a/app/.env.example
+++ b/app/.env.example
@@ -20,6 +20,7 @@ VITE_SHOW_ROUTES=
# ===== Test Credentials =====
RESEND_API_KEY=
+PLUNK_API_KEY=
R2_TOKEN=
R2_ACCESS_KEY=
diff --git a/app/package.json b/app/package.json
index 52268cf..1870b71 100644
--- a/app/package.json
+++ b/app/package.json
@@ -13,7 +13,7 @@
"bugs": {
"url": "https://github.com/bknd-io/bknd/issues"
},
- "packageManager": "bun@1.3.1",
+ "packageManager": "bun@1.3.2",
"engines": {
"node": ">=22.13"
},
diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts
index 50a82a8..c6de63f 100644
--- a/app/src/adapter/nextjs/nextjs.adapter.ts
+++ b/app/src/adapter/nextjs/nextjs.adapter.ts
@@ -19,7 +19,9 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ
if (!cleanRequest) return req;
const url = new URL(req.url);
- cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k));
+ cleanRequest?.searchParams?.forEach((k) => {
+ url.searchParams.delete(k);
+ });
if (isNode()) {
return new Request(url.toString(), {
diff --git a/app/src/core/drivers/email/plunk.spec.ts b/app/src/core/drivers/email/plunk.spec.ts
new file mode 100644
index 0000000..82fb544
--- /dev/null
+++ b/app/src/core/drivers/email/plunk.spec.ts
@@ -0,0 +1,55 @@
+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 = "
Test Email
This is a test email
";
+ 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: "This is HTML
",
+ });
+ expect(response).toBeDefined();
+ expect(response.success).toBe(true);
+ });
+});
diff --git a/app/src/core/drivers/email/plunk.ts b/app/src/core/drivers/email/plunk.ts
new file mode 100644
index 0000000..a3c7761
--- /dev/null
+++ b/app/src/core/drivers/email/plunk.ts
@@ -0,0 +1,70 @@
+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;
+};
+
+export type PlunkEmailResponse = {
+ success: boolean;
+ emails: Array<{
+ contact: {
+ id: string;
+ email: string;
+ };
+ email: string;
+ }>;
+ timestamp: string;
+};
+
+export const plunkEmail = (
+ config: PlunkEmailOptions,
+): IEmailDriver => {
+ 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;
+ },
+ };
+};
diff --git a/app/src/core/drivers/index.ts b/app/src/core/drivers/index.ts
index da356b7..963b9c4 100644
--- a/app/src/core/drivers/index.ts
+++ b/app/src/core/drivers/index.ts
@@ -5,3 +5,4 @@ export type { IEmailDriver } from "./email";
export { resendEmail } from "./email/resend";
export { sesEmail } from "./email/ses";
export { mailchannelsEmail } from "./email/mailchannels";
+export { plunkEmail } from "./email/plunk";
diff --git a/app/src/core/utils/strings.ts b/app/src/core/utils/strings.ts
index c052331..6d0d4ed 100644
--- a/app/src/core/utils/strings.ts
+++ b/app/src/core/utils/strings.ts
@@ -120,17 +120,14 @@ export function patternMatch(target: string, pattern: RegExp | string): boolean
}
export function slugify(str: string): string {
- return (
- String(str)
- .normalize("NFKD") // split accented characters into their base characters and diacritical marks
- // biome-ignore lint/suspicious/noMisleadingCharacterClass:
- .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
- .trim() // trim leading or trailing whitespace
- .toLowerCase() // convert to lowercase
- .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
- .replace(/\s+/g, "-") // replace spaces with hyphens
- .replace(/-+/g, "-") // remove consecutive hyphens
- );
+ return String(str)
+ .normalize("NFKD") // split accented characters into their base characters and diacritical marks
+ .replace(/[\u0300-\u036f]/g, "") // remove all the accents, which happen to be all in the \u03xx UNICODE block.
+ .trim() // trim leading or trailing whitespace
+ .toLowerCase() // convert to lowercase
+ .replace(/[^a-z0-9 -]/g, "") // remove non-alphanumeric characters
+ .replace(/\s+/g, "-") // replace spaces with hyphens
+ .replace(/-+/g, "-"); // remove consecutive hyphens
}
export function truncate(str: string, length = 50, end = "..."): string {
diff --git a/app/src/modules/db/DbModuleManager.ts b/app/src/modules/db/DbModuleManager.ts
index 8af95e8..51e78ff 100644
--- a/app/src/modules/db/DbModuleManager.ts
+++ b/app/src/modules/db/DbModuleManager.ts
@@ -1,4 +1,4 @@
-import { mark, stripMark, $console, s, SecretSchema, setPath } from "bknd/utils";
+import { mark, stripMark, $console, s, setPath } from "bknd/utils";
import { BkndError } from "core/errors";
import * as $diff from "core/object/diff";
import type { Connection } from "data/connection";
@@ -290,13 +290,12 @@ export class DbModuleManager extends ModuleManager {
updated_at: new Date(),
});
}
- } else if (e instanceof TransformPersistFailedException) {
- $console.error("ModuleManager: Cannot save invalid config");
- this.revertModules();
- throw e;
} else {
+ if (e instanceof TransformPersistFailedException) {
+ $console.error("ModuleManager: Cannot save invalid config");
+ }
$console.error("ModuleManager: Aborting");
- this.revertModules();
+ await this.revertModules();
throw e;
}
}
diff --git a/biome.json b/biome.json
index a1417f7..278fd1c 100644
--- a/biome.json
+++ b/biome.json
@@ -7,7 +7,8 @@
"formatter": {
"enabled": true,
"indentStyle": "space",
- "formatWithErrors": true
+ "formatWithErrors": true,
+ "includes": ["**", "!!**/package.json"]
},
"javascript": {
"formatter": {
@@ -31,21 +32,21 @@
"files": {
"includes": [
"**",
- "!**/node_modules",
- "!**/node_modules",
- "!**/.cache",
- "!**/.wrangler",
- "!**/build",
- "!**/dist",
- "!**/data.sqld",
- "!**/data.sqld",
- "!**/public",
- "!**/.history"
+ "!!**/.tsup",
+ "!!**/node_modules",
+ "!!**/.cache",
+ "!!**/.wrangler",
+ "!!**/build",
+ "!!**/dist",
+ "!!**/data.sqld",
+ "!!**/data.sqld",
+ "!!**/public",
+ "!!**/.history"
]
},
"linter": {
"enabled": true,
- "includes": ["**"],
+ "includes": ["**", "!!**/vitest.config.ts", "!!app/build.ts"],
"rules": {
"recommended": true,
"a11y": {},
@@ -53,7 +54,13 @@
"useExhaustiveDependencies": "off",
"noUnreachable": "warn",
"noChildrenProp": "off",
- "noSwitchDeclarations": "warn"
+ "noSwitchDeclarations": "warn",
+ "noUnusedVariables": {
+ "options": {
+ "ignoreRestSiblings": true
+ },
+ "level": "warn"
+ }
},
"complexity": {
"noUselessFragments": "warn",
@@ -68,7 +75,11 @@
"noArrayIndexKey": "off",
"noImplicitAnyLet": "warn",
"noConfusingVoidType": "off",
- "noConsole": { "level": "warn", "options": { "allow": ["error"] } }
+ "noConsole": {
+ "level": "warn",
+ "options": { "allow": ["error", "info"] }
+ },
+ "noTsIgnore": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
diff --git a/bun.lock b/bun.lock
index 25ee886..8690613 100644
--- a/bun.lock
+++ b/bun.lock
@@ -3896,6 +3896,8 @@
"@babel/traverse/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
+ "@bknd/plasmic/@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
+
"@bundled-es-modules/tough-cookie/tough-cookie": ["tough-cookie@4.1.4", "", { "dependencies": { "psl": "^1.1.33", "punycode": "^2.1.1", "universalify": "^0.2.0", "url-parse": "^1.5.3" } }, "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag=="],
"@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20251011.2", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "7.14.0", "workerd": "1.20251011.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-5oAaz6lqZus4QFwzEJiNtgpjZR2TBVwBeIhOW33V4gu+l23EukpKja831tFIX2o6sOD/hqZmKZHplOrWl3YGtQ=="],
@@ -4792,6 +4794,8 @@
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.3", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg=="],
+ "@bknd/plasmic/@types/bun/bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
+
"@bundled-es-modules/tough-cookie/tough-cookie/universalify": ["universalify@0.2.0", "", {}, "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="],
"@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
@@ -5294,6 +5298,8 @@
"@babel/preset-env/babel-plugin-polyfill-regenerator/@babel/helper-define-polyfill-provider/debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="],
+ "@bknd/plasmic/@types/bun/bun-types/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
+
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251011.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251011.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA=="],
@@ -5524,6 +5530,8 @@
"wrangler/miniflare/youch/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
+ "@bknd/plasmic/@types/bun/bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
"@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@vitejs/plugin-react/@babel/core/@babel/generator/@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
diff --git a/package.json b/package.json
index c7c9b9d..83b56c2 100644
--- a/package.json
+++ b/package.json
@@ -3,12 +3,16 @@
"private": true,
"sideEffects": false,
"type": "module",
+ "packageManager": "bun@1.3.2",
+ "engines": {
+ "node": ">=22.13"
+ },
"scripts": {
"updater": "bun x npm-check-updates -ui",
"ci": "find . -name 'node_modules' -type d -exec rm -rf {} + && bun install",
"npm:local": "verdaccio --config verdaccio.yml",
"format": "bunx biome format --write ./app",
- "lint": "bunx biome lint --changed ./app"
+ "lint": "bunx biome lint --changed --write ./app"
},
"dependencies": {},
"devDependencies": {
@@ -20,8 +24,5 @@
"typescript": "^5.9.3",
"verdaccio": "^6.2.1"
},
- "engines": {
- "node": ">=22"
- },
"workspaces": ["app", "packages/*"]
}