mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
finalize initial app resources/drivers
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
8
app/src/core/drivers/cache/in-memory.spec.ts
vendored
8
app/src/core/drivers/cache/in-memory.spec.ts
vendored
@@ -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();
|
||||
});
|
||||
|
||||
2
app/src/core/drivers/cache/in-memory.ts
vendored
2
app/src/core/drivers/cache/in-memory.ts
vendored
@@ -118,6 +118,6 @@ export class InMemoryCacheDriver implements ICacheDriver {
|
||||
}
|
||||
}
|
||||
|
||||
export const cacheMemory = (options?: InMemoryCacheOptions) => {
|
||||
export const memoryCache = (options?: InMemoryCacheOptions) => {
|
||||
return new InMemoryCacheDriver(options);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
app/src/core/drivers/email/mailchannels.spec.ts
Normal file
20
app/src/core/drivers/email/mailchannels.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
21
app/src/core/drivers/email/resend.spec.ts
Normal file
21
app/src/core/drivers/email/resend.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
InvalidSchemaError,
|
||||
} from "./object/schema";
|
||||
|
||||
export * from "./drivers";
|
||||
export * from "./console";
|
||||
export * from "./events";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user