From aaa97ed113a4cc561435ae10e25fd0f112d1602a Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 17 Jun 2025 19:51:12 +0200 Subject: [PATCH] finalize initial app resources/drivers --- app/__test__/App.spec.ts | 38 +++++++++ app/src/App.ts | 12 ++- app/src/core/drivers/cache/in-memory.spec.ts | 8 +- app/src/core/drivers/cache/in-memory.ts | 2 +- app/src/core/drivers/email/index.ts | 27 +++++-- .../core/drivers/email/mailchannels.spec.ts | 20 +++++ app/src/core/drivers/email/mailchannels.ts | 13 ++-- app/src/core/drivers/email/resend.spec.ts | 21 +++++ app/src/core/drivers/email/resend.ts | 8 +- app/src/core/drivers/email/ses.ts | 78 ++++++++++--------- app/src/core/drivers/index.ts | 4 +- app/src/core/index.ts | 1 + 12 files changed, 170 insertions(+), 62 deletions(-) create mode 100644 app/src/core/drivers/email/mailchannels.spec.ts create mode 100644 app/src/core/drivers/email/resend.spec.ts diff --git a/app/__test__/App.spec.ts b/app/__test__/App.spec.ts index 8b6b672..0b456e1 100644 --- a/app/__test__/App.spec.ts +++ b/app/__test__/App.spec.ts @@ -104,4 +104,42 @@ describe("App tests", async () => { expect(app.plugins.size).toBe(1); expect(Array.from(app.plugins.keys())).toEqual(["test"]); }); + + test.only("drivers", async () => { + const called: string[] = []; + const app = new App(dummyConnection, undefined, { + drivers: { + email: { + send: async (to, subject, body) => { + called.push("email.send"); + return { + id: "", + }; + }, + }, + cache: { + get: async (key) => { + called.push("cache.get"); + return ""; + }, + set: async (key, value, ttl) => { + called.push("cache.set"); + }, + del: async (key) => { + called.push("cache.del"); + }, + }, + }, + }); + await app.build(); + + expect(app.drivers.cache).toBeDefined(); + expect(app.drivers.email).toBeDefined(); + await app.drivers.email.send("", "", ""); + await app.drivers.cache.get(""); + await app.drivers.cache.set("", "", 0); + await app.drivers.cache.del(""); + + expect(called).toEqual(["email.send", "cache.get", "cache.set", "cache.del"]); + }); }); diff --git a/app/src/App.ts b/app/src/App.ts index c12cdf3..223711a 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -76,24 +76,30 @@ export type CreateAppConfig = { export type AppConfig = InitialModuleConfigs; export type LocalApiOptions = Request | ApiOptions; -export class App { +export class App { static readonly Events = AppEvents; modules: ModuleManager; adminController?: AdminController; _id: string = crypto.randomUUID(); plugins: Map = new Map(); + drivers: Options["drivers"] = {}; private trigger_first_boot = false; private _building: boolean = false; constructor( - public connection: Connection, + public connection: C, _initialConfig?: InitialModuleConfigs, - private options?: AppOptions, + private options?: Options, ) { + this.drivers = options?.drivers ?? {}; + for (const plugin of options?.plugins ?? []) { const config = plugin(this); + if (this.plugins.has(config.name)) { + throw new Error(`Plugin ${config.name} already registered`); + } this.plugins.set(config.name, config); } this.runPlugins("onBoot"); diff --git a/app/src/core/drivers/cache/in-memory.spec.ts b/app/src/core/drivers/cache/in-memory.spec.ts index af1486e..3bde9a8 100644 --- a/app/src/core/drivers/cache/in-memory.spec.ts +++ b/app/src/core/drivers/cache/in-memory.spec.ts @@ -1,5 +1,5 @@ import { cacheDriverTestSuite } from "./cache-driver-test-suite"; -import { cacheMemory } from "./in-memory"; +import { memoryCache } from "./in-memory"; import { bunTestRunner } from "adapter/bun/test"; import { setSystemTime, afterAll, beforeAll, test, expect, describe } from "bun:test"; @@ -16,7 +16,7 @@ afterAll(() => { describe("InMemoryCacheDriver", () => { cacheDriverTestSuite(bunTestRunner, { - makeCache: () => cacheMemory(), + makeCache: () => memoryCache(), setTime: (ms: number) => { setSystemTime(new Date(baseTime + ms)); }, @@ -24,7 +24,7 @@ describe("InMemoryCacheDriver", () => { test("evicts least recently used entries by byte size", async () => { // maxSize = 20 bytes for this test - const cache = cacheMemory({ maxSize: 20 }); + const cache = memoryCache({ maxSize: 20 }); // each key and value is 1 char = 1 byte (ASCII) // totals to 2 bytes each await cache.set("a", "1"); @@ -45,7 +45,7 @@ describe("InMemoryCacheDriver", () => { }); test("throws if entry is too large to ever fit", async () => { - const cache = cacheMemory({ maxSize: 5 }); + const cache = memoryCache({ maxSize: 5 }); // key: 3, value: 10 = 13 bytes expect(cache.set("big", "1234567890")).rejects.toThrow(); }); diff --git a/app/src/core/drivers/cache/in-memory.ts b/app/src/core/drivers/cache/in-memory.ts index 45aaece..9ff7ef7 100644 --- a/app/src/core/drivers/cache/in-memory.ts +++ b/app/src/core/drivers/cache/in-memory.ts @@ -118,6 +118,6 @@ export class InMemoryCacheDriver implements ICacheDriver { } } -export const cacheMemory = (options?: InMemoryCacheOptions) => { +export const memoryCache = (options?: InMemoryCacheOptions) => { return new InMemoryCacheDriver(options); }; diff --git a/app/src/core/drivers/email/index.ts b/app/src/core/drivers/email/index.ts index 646942c..494276d 100644 --- a/app/src/core/drivers/email/index.ts +++ b/app/src/core/drivers/email/index.ts @@ -1,13 +1,28 @@ -export type TEmailResponse = { - success: boolean; - data?: Data; -}; - export interface IEmailDriver { send( to: string, subject: string, body: string | { text: string; html: string }, options?: Options, - ): Promise>; + ): Promise; } + +import type { BkndConfig } from "bknd"; +import { resendEmail, memoryCache } from "bknd/core"; + +export default { + onBuilt: async (app) => { + app.server.get("/send-email", async (c) => { + if (await app.drivers?.email?.send("test@test.com", "Test", "Test")) { + return c.text("success"); + } + return c.text("failed"); + }); + }, + options: { + drivers: { + email: resendEmail({ apiKey: "..." }), + cache: memoryCache(), + }, + }, +} as const satisfies BkndConfig; diff --git a/app/src/core/drivers/email/mailchannels.spec.ts b/app/src/core/drivers/email/mailchannels.spec.ts new file mode 100644 index 0000000..5281856 --- /dev/null +++ b/app/src/core/drivers/email/mailchannels.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "bun:test"; +import { mailchannelsEmail } from "./mailchannels"; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("mailchannels", () => { + it("should throw on failed", async () => { + const driver = mailchannelsEmail({ apiKey: "invalid" } as any); + expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); + }); + + it("should send an email", async () => { + const driver = mailchannelsEmail({ + apiKey: process.env.MAILCHANNELS_API_KEY!, + from: { email: "accounts@bknd.io", name: "Dennis Senn" }, + }); + const response = await driver.send("ds@bknd.io", "Test", "Test"); + expect(response).toBeDefined(); + }); +}); diff --git a/app/src/core/drivers/email/mailchannels.ts b/app/src/core/drivers/email/mailchannels.ts index 1df7968..7478ef5 100644 --- a/app/src/core/drivers/email/mailchannels.ts +++ b/app/src/core/drivers/email/mailchannels.ts @@ -94,7 +94,7 @@ export const mailchannelsEmail = ( }, ], }, - options, + options ?? {}, ); const res = await fetch(host, { @@ -103,14 +103,15 @@ export const mailchannelsEmail = ( "Content-Type": "application/json", "X-Api-Key": config.apiKey, }, - body: JSON.stringify({ ...payload, ...options }), + body: JSON.stringify(payload), }); + const data = (await res.json()) as MailchannelsEmailResponse; - if (res.ok) { - const data = (await res.json()) as MailchannelsEmailResponse; - return { success: true, data }; + if (data?.results.length === 0 || data.results?.[0]?.status !== "sent") { + throw new Error(data.results?.[0]?.reason ?? "Unknown error"); } - return { success: false }; + + return (await res.json()) as MailchannelsEmailResponse; }, }; }; diff --git a/app/src/core/drivers/email/resend.spec.ts b/app/src/core/drivers/email/resend.spec.ts new file mode 100644 index 0000000..5c1cfcf --- /dev/null +++ b/app/src/core/drivers/email/resend.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "bun:test"; +import { resendEmail } from "./resend"; + +const ALL_TESTS = !!process.env.ALL_TESTS; + +describe.skipIf(ALL_TESTS)("resend", () => { + it.only("should throw on failed", async () => { + const driver = resendEmail({ apiKey: "invalid" } as any); + expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow(); + }); + + it("should send an email", async () => { + const driver = resendEmail({ + apiKey: process.env.RESEND_API_KEY!, + from: "BKND ", + }); + const response = await driver.send("help@bknd.io", "Test", "Test"); + expect(response).toBeDefined(); + expect(response.id).toBeDefined(); + }); +}); diff --git a/app/src/core/drivers/email/resend.ts b/app/src/core/drivers/email/resend.ts index 8cd79c9..7ac7484 100644 --- a/app/src/core/drivers/email/resend.ts +++ b/app/src/core/drivers/email/resend.ts @@ -62,11 +62,11 @@ export const resendEmail = ( body: JSON.stringify({ ...payload, ...options }), }); - if (res.ok) { - const data = (await res.json()) as ResendEmailResponse; - return { success: true, data }; + if (!res.ok) { + throw new Error(await res.text()); } - return { success: false }; + + return (await res.json()) as ResendEmailResponse; }, }; }; diff --git a/app/src/core/drivers/email/ses.ts b/app/src/core/drivers/email/ses.ts index 8748b66..1de194e 100644 --- a/app/src/core/drivers/email/ses.ts +++ b/app/src/core/drivers/email/ses.ts @@ -15,7 +15,7 @@ export type SesSendOptions = { }; export type SesEmailResponse = { - MessageId?: string; + MessageId: string; status: number; body: string; }; @@ -23,7 +23,7 @@ export type SesEmailResponse = { export const sesEmail = ( config: SesEmailOptions, ): IEmailDriver => { - const endpoint = `https://email.${config.region}.amazonaws.com/`; + const endpoint = `https://email.${config.region}.amazonaws.com/v2/email/outbound-emails`; const from = config.from; const aws = new AwsClient({ accessKeyId: config.accessKeyId, @@ -38,52 +38,56 @@ export const sesEmail = ( body: string | { text: string; html: string }, options?: SesSendOptions, ) => { - // build SES SendEmail params (x-www-form-urlencoded) - const params: Record = { - Action: "SendEmail", - Version: "2010-12-01", - Source: from, - "Destination.ToAddresses.member.1": to, - "Message.Subject.Data": subject, + // SES v2 SendEmail JSON payload + const payload: any = { + FromEmailAddress: from, + Destination: { + ToAddresses: [to], + }, + Content: { + Simple: { + Subject: { Data: subject, Charset: "UTF-8" }, + Body: {}, + }, + }, }; if (typeof body === "string") { - params["Message.Body.Html.Data"] = body; + payload.Content.Simple.Body.Html = { Data: body, Charset: "UTF-8" }; } else { - params["Message.Body.Html.Data"] = body.html; - params["Message.Body.Text.Data"] = body.text; + if (body.html) payload.Content.Simple.Body.Html = { Data: body.html, Charset: "UTF-8" }; + if (body.text) payload.Content.Simple.Body.Text = { Data: body.text, Charset: "UTF-8" }; } - if (options?.cc) { - options.cc.forEach((cc, i) => { - params[`Destination.CcAddresses.member.${i + 1}`] = cc; - }); + if (options?.cc && options.cc.length > 0) { + payload.Destination.CcAddresses = options.cc; } - if (options?.bcc) { - options.bcc.forEach((bcc, i) => { - params[`Destination.BccAddresses.member.${i + 1}`] = bcc; - }); + if (options?.bcc && options.bcc.length > 0) { + payload.Destination.BccAddresses = options.bcc; } - if (options?.replyTo) { - options.replyTo.forEach((reply, i) => { - params[`ReplyToAddresses.member.${i + 1}`] = reply; - }); + if (options?.replyTo && options.replyTo.length > 0) { + payload.ReplyToAddresses = options.replyTo; } - const formBody = Object.entries(params) - .map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v)) - .join("&"); const res = await aws.fetch(endpoint, { method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - body: formBody, + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), }); const text = await res.text(); - // try to extract MessageId from XML response - let MessageId: string | undefined = undefined; - const match = text.match(/([^<]+)<\/MessageId>/); - if (match) MessageId = match[1]; - return { - success: res.ok, - data: { MessageId, status: res.status, body: text }, - }; + if (!res.ok) { + // SES v2 returns JSON error body + let errorMsg = text; + try { + const err = JSON.parse(text); + errorMsg = err.message || err.Message || text; + } catch {} + throw new Error(`SES SendEmail failed: ${errorMsg}`); + } + // parse MessageId from JSON response + let MessageId: string = ""; + try { + const data = JSON.parse(text); + MessageId = data.MessageId; + } catch {} + return { MessageId, status: res.status, body: text }; }, }; }; diff --git a/app/src/core/drivers/index.ts b/app/src/core/drivers/index.ts index a587df1..da356b7 100644 --- a/app/src/core/drivers/index.ts +++ b/app/src/core/drivers/index.ts @@ -1,5 +1,7 @@ export type { ICacheDriver } from "./cache"; -export { cacheMemory } from "./cache/in-memory"; +export { memoryCache } from "./cache/in-memory"; export type { IEmailDriver } from "./email"; export { resendEmail } from "./email/resend"; +export { sesEmail } from "./email/ses"; +export { mailchannelsEmail } from "./email/mailchannels"; diff --git a/app/src/core/index.ts b/app/src/core/index.ts index ae33dd6..a0e96e6 100644 --- a/app/src/core/index.ts +++ b/app/src/core/index.ts @@ -37,6 +37,7 @@ export { InvalidSchemaError, } from "./object/schema"; +export * from "./drivers"; export * from "./console"; export * from "./events";