finalize initial app resources/drivers

This commit is contained in:
dswbx
2025-06-17 19:51:12 +02:00
parent 69c8aec6fb
commit aaa97ed113
12 changed files with 170 additions and 62 deletions

View File

@@ -104,4 +104,42 @@ describe("App tests", async () => {
expect(app.plugins.size).toBe(1); expect(app.plugins.size).toBe(1);
expect(Array.from(app.plugins.keys())).toEqual(["test"]); 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"]);
});
}); });

View File

@@ -76,24 +76,30 @@ export type CreateAppConfig = {
export type AppConfig = InitialModuleConfigs; export type AppConfig = InitialModuleConfigs;
export type LocalApiOptions = Request | ApiOptions; export type LocalApiOptions = Request | ApiOptions;
export class App { export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
static readonly Events = AppEvents; static readonly Events = AppEvents;
modules: ModuleManager; modules: ModuleManager;
adminController?: AdminController; adminController?: AdminController;
_id: string = crypto.randomUUID(); _id: string = crypto.randomUUID();
plugins: Map<string, AppPluginConfig> = new Map(); plugins: Map<string, AppPluginConfig> = new Map();
drivers: Options["drivers"] = {};
private trigger_first_boot = false; private trigger_first_boot = false;
private _building: boolean = false; private _building: boolean = false;
constructor( constructor(
public connection: Connection, public connection: C,
_initialConfig?: InitialModuleConfigs, _initialConfig?: InitialModuleConfigs,
private options?: AppOptions, private options?: Options,
) { ) {
this.drivers = options?.drivers ?? {};
for (const plugin of options?.plugins ?? []) { for (const plugin of options?.plugins ?? []) {
const config = plugin(this); 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.plugins.set(config.name, config);
} }
this.runPlugins("onBoot"); this.runPlugins("onBoot");

View File

@@ -1,5 +1,5 @@
import { cacheDriverTestSuite } from "./cache-driver-test-suite"; import { cacheDriverTestSuite } from "./cache-driver-test-suite";
import { cacheMemory } from "./in-memory"; import { memoryCache } from "./in-memory";
import { bunTestRunner } from "adapter/bun/test"; import { bunTestRunner } from "adapter/bun/test";
import { setSystemTime, afterAll, beforeAll, test, expect, describe } from "bun:test"; import { setSystemTime, afterAll, beforeAll, test, expect, describe } from "bun:test";
@@ -16,7 +16,7 @@ afterAll(() => {
describe("InMemoryCacheDriver", () => { describe("InMemoryCacheDriver", () => {
cacheDriverTestSuite(bunTestRunner, { cacheDriverTestSuite(bunTestRunner, {
makeCache: () => cacheMemory(), makeCache: () => memoryCache(),
setTime: (ms: number) => { setTime: (ms: number) => {
setSystemTime(new Date(baseTime + ms)); setSystemTime(new Date(baseTime + ms));
}, },
@@ -24,7 +24,7 @@ describe("InMemoryCacheDriver", () => {
test("evicts least recently used entries by byte size", async () => { test("evicts least recently used entries by byte size", async () => {
// maxSize = 20 bytes for this test // 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) // each key and value is 1 char = 1 byte (ASCII)
// totals to 2 bytes each // totals to 2 bytes each
await cache.set("a", "1"); await cache.set("a", "1");
@@ -45,7 +45,7 @@ describe("InMemoryCacheDriver", () => {
}); });
test("throws if entry is too large to ever fit", async () => { 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 // key: 3, value: 10 = 13 bytes
expect(cache.set("big", "1234567890")).rejects.toThrow(); expect(cache.set("big", "1234567890")).rejects.toThrow();
}); });

View File

@@ -118,6 +118,6 @@ export class InMemoryCacheDriver implements ICacheDriver {
} }
} }
export const cacheMemory = (options?: InMemoryCacheOptions) => { export const memoryCache = (options?: InMemoryCacheOptions) => {
return new InMemoryCacheDriver(options); return new InMemoryCacheDriver(options);
}; };

View File

@@ -1,13 +1,28 @@
export type TEmailResponse<Data = unknown> = {
success: boolean;
data?: Data;
};
export interface IEmailDriver<Data = unknown, Options = object> { export interface IEmailDriver<Data = unknown, Options = object> {
send( send(
to: string, to: string,
subject: string, subject: string,
body: string | { text: string; html: string }, body: string | { text: string; html: string },
options?: Options, options?: Options,
): Promise<TEmailResponse<Data>>; ): Promise<Data>;
} }
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;

View File

@@ -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();
});
});

View File

@@ -94,7 +94,7 @@ export const mailchannelsEmail = (
}, },
], ],
}, },
options, options ?? {},
); );
const res = await fetch(host, { const res = await fetch(host, {
@@ -103,14 +103,15 @@ export const mailchannelsEmail = (
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Api-Key": config.apiKey, "X-Api-Key": config.apiKey,
}, },
body: JSON.stringify({ ...payload, ...options }), body: JSON.stringify(payload),
}); });
if (res.ok) {
const data = (await res.json()) as MailchannelsEmailResponse; 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;
}, },
}; };
}; };

View File

@@ -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 <help@bknd.io>",
});
const response = await driver.send("help@bknd.io", "Test", "Test");
expect(response).toBeDefined();
expect(response.id).toBeDefined();
});
});

View File

@@ -62,11 +62,11 @@ export const resendEmail = (
body: JSON.stringify({ ...payload, ...options }), body: JSON.stringify({ ...payload, ...options }),
}); });
if (res.ok) { if (!res.ok) {
const data = (await res.json()) as ResendEmailResponse; throw new Error(await res.text());
return { success: true, data };
} }
return { success: false };
return (await res.json()) as ResendEmailResponse;
}, },
}; };
}; };

View File

@@ -15,7 +15,7 @@ export type SesSendOptions = {
}; };
export type SesEmailResponse = { export type SesEmailResponse = {
MessageId?: string; MessageId: string;
status: number; status: number;
body: string; body: string;
}; };
@@ -23,7 +23,7 @@ export type SesEmailResponse = {
export const sesEmail = ( export const sesEmail = (
config: SesEmailOptions, config: SesEmailOptions,
): IEmailDriver<SesEmailResponse, SesSendOptions> => { ): IEmailDriver<SesEmailResponse, SesSendOptions> => {
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 from = config.from;
const aws = new AwsClient({ const aws = new AwsClient({
accessKeyId: config.accessKeyId, accessKeyId: config.accessKeyId,
@@ -38,52 +38,56 @@ export const sesEmail = (
body: string | { text: string; html: string }, body: string | { text: string; html: string },
options?: SesSendOptions, options?: SesSendOptions,
) => { ) => {
// build SES SendEmail params (x-www-form-urlencoded) // SES v2 SendEmail JSON payload
const params: Record<string, string> = { const payload: any = {
Action: "SendEmail", FromEmailAddress: from,
Version: "2010-12-01", Destination: {
Source: from, ToAddresses: [to],
"Destination.ToAddresses.member.1": to, },
"Message.Subject.Data": subject, Content: {
Simple: {
Subject: { Data: subject, Charset: "UTF-8" },
Body: {},
},
},
}; };
if (typeof body === "string") { if (typeof body === "string") {
params["Message.Body.Html.Data"] = body; payload.Content.Simple.Body.Html = { Data: body, Charset: "UTF-8" };
} else { } else {
params["Message.Body.Html.Data"] = body.html; if (body.html) payload.Content.Simple.Body.Html = { Data: body.html, Charset: "UTF-8" };
params["Message.Body.Text.Data"] = body.text; if (body.text) payload.Content.Simple.Body.Text = { Data: body.text, Charset: "UTF-8" };
} }
if (options?.cc) { if (options?.cc && options.cc.length > 0) {
options.cc.forEach((cc, i) => { payload.Destination.CcAddresses = options.cc;
params[`Destination.CcAddresses.member.${i + 1}`] = cc;
});
} }
if (options?.bcc) { if (options?.bcc && options.bcc.length > 0) {
options.bcc.forEach((bcc, i) => { payload.Destination.BccAddresses = options.bcc;
params[`Destination.BccAddresses.member.${i + 1}`] = bcc;
});
} }
if (options?.replyTo) { if (options?.replyTo && options.replyTo.length > 0) {
options.replyTo.forEach((reply, i) => { payload.ReplyToAddresses = options.replyTo;
params[`ReplyToAddresses.member.${i + 1}`] = reply;
});
} }
const formBody = Object.entries(params)
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v))
.join("&");
const res = await aws.fetch(endpoint, { const res = await aws.fetch(endpoint, {
method: "POST", method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" }, headers: { "content-type": "application/json" },
body: formBody, body: JSON.stringify(payload),
}); });
const text = await res.text(); const text = await res.text();
// try to extract MessageId from XML response if (!res.ok) {
let MessageId: string | undefined = undefined; // SES v2 returns JSON error body
const match = text.match(/<MessageId>([^<]+)<\/MessageId>/); let errorMsg = text;
if (match) MessageId = match[1]; try {
return { const err = JSON.parse(text);
success: res.ok, errorMsg = err.message || err.Message || text;
data: { MessageId, status: res.status, body: 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 };
}, },
}; };
}; };

View File

@@ -1,5 +1,7 @@
export type { ICacheDriver } from "./cache"; export type { ICacheDriver } from "./cache";
export { cacheMemory } from "./cache/in-memory"; export { memoryCache } from "./cache/in-memory";
export type { IEmailDriver } from "./email"; export type { IEmailDriver } from "./email";
export { resendEmail } from "./email/resend"; export { resendEmail } from "./email/resend";
export { sesEmail } from "./email/ses";
export { mailchannelsEmail } from "./email/mailchannels";

View File

@@ -37,6 +37,7 @@ export {
InvalidSchemaError, InvalidSchemaError,
} from "./object/schema"; } from "./object/schema";
export * from "./drivers";
export * from "./console"; export * from "./console";
export * from "./events"; export * from "./events";