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(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"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
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 { 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();
|
||||||
});
|
});
|
||||||
|
|||||||
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);
|
return new InMemoryCacheDriver(options);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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, {
|
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),
|
||||||
});
|
});
|
||||||
|
const data = (await res.json()) as MailchannelsEmailResponse;
|
||||||
|
|
||||||
if (res.ok) {
|
if (data?.results.length === 0 || data.results?.[0]?.status !== "sent") {
|
||||||
const data = (await res.json()) as MailchannelsEmailResponse;
|
throw new Error(data.results?.[0]?.reason ?? "Unknown error");
|
||||||
return { success: true, data };
|
|
||||||
}
|
}
|
||||||
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 }),
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user