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(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 LocalApiOptions = Request | ApiOptions;
export class App {
export class App<C extends Connection = Connection, Options extends AppOptions = AppOptions> {
static readonly Events = AppEvents;
modules: ModuleManager;
adminController?: AdminController;
_id: string = crypto.randomUUID();
plugins: Map<string, AppPluginConfig> = 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");

View File

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

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

View File

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

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 }),
});
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;
},
};
};

View File

@@ -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<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 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<string, string> = {
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>([^<]+)<\/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 };
},
};
};

View File

@@ -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";

View File

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