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/*"] }