mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
init app resources
This commit is contained in:
@@ -17,6 +17,7 @@ import { AdminController, type AdminControllerOptions } from "modules/server/Adm
|
|||||||
import { SystemController } from "modules/server/SystemController";
|
import { SystemController } from "modules/server/SystemController";
|
||||||
import type { MaybePromise } from "core/types";
|
import type { MaybePromise } from "core/types";
|
||||||
import type { ServerEnv } from "modules/Controller";
|
import type { ServerEnv } from "modules/Controller";
|
||||||
|
import type { IEmailDriver, ICacheDriver } from "core/drivers";
|
||||||
|
|
||||||
// biome-ignore format: must be here
|
// biome-ignore format: must be here
|
||||||
import { Api, type ApiOptions } from "Api";
|
import { Api, type ApiOptions } from "Api";
|
||||||
@@ -61,6 +62,10 @@ export type AppOptions = {
|
|||||||
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
|
seed?: (ctx: ModuleBuildContext & { app: App }) => Promise<void>;
|
||||||
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
manager?: Omit<ModuleManagerOptions, "initial" | "onUpdated" | "seed">;
|
||||||
asyncEventsMode?: "sync" | "async" | "none";
|
asyncEventsMode?: "sync" | "async" | "none";
|
||||||
|
drivers?: {
|
||||||
|
email?: IEmailDriver;
|
||||||
|
cache?: ICacheDriver;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export type CreateAppConfig = {
|
export type CreateAppConfig = {
|
||||||
connection?: Connection | { url: string };
|
connection?: Connection | { url: string };
|
||||||
|
|||||||
45
app/src/adapter/cloudflare/drivers/cache.ts
Normal file
45
app/src/adapter/cloudflare/drivers/cache.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ICacheDriver } from "core/drivers";
|
||||||
|
|
||||||
|
interface WorkersKVCacheOptions {
|
||||||
|
// default time-to-live in seconds
|
||||||
|
defaultTTL?: number;
|
||||||
|
// prefix for the cache key
|
||||||
|
cachePrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkersKVCacheDriver implements ICacheDriver {
|
||||||
|
protected readonly kv: KVNamespace;
|
||||||
|
protected readonly defaultTTL?: number;
|
||||||
|
protected readonly cachePrefix: string;
|
||||||
|
|
||||||
|
constructor(kv: KVNamespace, options: WorkersKVCacheOptions = {}) {
|
||||||
|
this.kv = kv;
|
||||||
|
this.cachePrefix = options.cachePrefix ?? "";
|
||||||
|
this.defaultTTL = options.defaultTTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected getKey(key: string): string {
|
||||||
|
return this.cachePrefix + key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | undefined> {
|
||||||
|
const value = await this.kv.get(this.getKey(key));
|
||||||
|
return value === null ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||||
|
let expirationTtl = ttl ?? this.defaultTTL;
|
||||||
|
if (expirationTtl) {
|
||||||
|
expirationTtl = Math.max(expirationTtl, 60);
|
||||||
|
}
|
||||||
|
await this.kv.put(this.getKey(key), value, { expirationTtl: expirationTtl });
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await this.kv.delete(this.getKey(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheWorkersKV = (kv: KVNamespace, options?: WorkersKVCacheOptions) => {
|
||||||
|
return new WorkersKVCacheDriver(kv, options);
|
||||||
|
};
|
||||||
34
app/src/adapter/cloudflare/drivers/cache.vitest.ts
Normal file
34
app/src/adapter/cloudflare/drivers/cache.vitest.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, vi, afterAll, beforeAll } from "vitest";
|
||||||
|
import { cacheWorkersKV } from "./cache";
|
||||||
|
import { viTestRunner } from "adapter/node/vitest";
|
||||||
|
import { cacheDriverTestSuite } from "core/drivers/cache/cache-driver-test-suite";
|
||||||
|
import { Miniflare } from "miniflare";
|
||||||
|
|
||||||
|
describe("cacheWorkersKV", async () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mf = new Miniflare({
|
||||||
|
modules: true,
|
||||||
|
script: "export default { async fetch() { return new Response(null); } }",
|
||||||
|
kvNamespaces: ["KV"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const kv = (await mf.getKVNamespace("KV")) as unknown as KVNamespace;
|
||||||
|
|
||||||
|
cacheDriverTestSuite(viTestRunner, {
|
||||||
|
makeCache: () => cacheWorkersKV(kv),
|
||||||
|
setTime: (ms: number) => {
|
||||||
|
vi.advanceTimersByTime(ms);
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
minTTL: 60,
|
||||||
|
// doesn't work with miniflare
|
||||||
|
skipTTL: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
72
app/src/core/drivers/cache/cache-driver-test-suite.ts
vendored
Normal file
72
app/src/core/drivers/cache/cache-driver-test-suite.ts
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { TestRunner } from "core/test";
|
||||||
|
import type { ICacheDriver } from "./index";
|
||||||
|
|
||||||
|
export function cacheDriverTestSuite(
|
||||||
|
testRunner: TestRunner,
|
||||||
|
{
|
||||||
|
makeCache,
|
||||||
|
setTime,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
makeCache: () => ICacheDriver;
|
||||||
|
setTime: (ms: number) => void;
|
||||||
|
options?: {
|
||||||
|
minTTL?: number;
|
||||||
|
skipTTL?: boolean;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { test, expect } = testRunner;
|
||||||
|
const minTTL = options?.minTTL ?? 0;
|
||||||
|
|
||||||
|
test("get within ttl", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
await cache.set("ttl", "bar", minTTL + 2); // 2 second TTL
|
||||||
|
setTime(minTTL * 1000 + 1000); // advance by 1 second
|
||||||
|
expect(await cache.get("ttl")).toBe("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set and get returns value", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
await cache.set("value", "bar");
|
||||||
|
expect(await cache.get("value")).toBe("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("get returns undefined for missing key", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
expect(await cache.get("missing" + Math.random())).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete removes value", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
await cache.set("delete", "bar");
|
||||||
|
await cache.del("delete");
|
||||||
|
expect(await cache.get("delete")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set overwrites value", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
await cache.set("overwrite", "bar");
|
||||||
|
await cache.set("overwrite", "baz");
|
||||||
|
expect(await cache.get("overwrite")).toBe("baz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("set with ttl expires", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
await cache.set("expire", "bar", minTTL + 1); // 1 second TTL
|
||||||
|
expect(await cache.get("expire")).toBe("bar");
|
||||||
|
// advance time
|
||||||
|
setTime(minTTL * 1000 * 2000);
|
||||||
|
if (options?.skipTTL) {
|
||||||
|
await cache.del("expire");
|
||||||
|
}
|
||||||
|
expect(await cache.get("expire")).toBeUndefined();
|
||||||
|
});
|
||||||
|
test("set without ttl does not expire", async () => {
|
||||||
|
const cache = makeCache();
|
||||||
|
await cache.set("ttl0", "bar");
|
||||||
|
expect(await cache.get("ttl0")).toBe("bar");
|
||||||
|
setTime(1000);
|
||||||
|
expect(await cache.get("ttl0")).toBe("bar");
|
||||||
|
});
|
||||||
|
}
|
||||||
52
app/src/core/drivers/cache/in-memory.spec.ts
vendored
Normal file
52
app/src/core/drivers/cache/in-memory.spec.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { cacheDriverTestSuite } from "./cache-driver-test-suite";
|
||||||
|
import { cacheMemory } from "./in-memory";
|
||||||
|
import { bunTestRunner } from "adapter/bun/test";
|
||||||
|
import { setSystemTime, afterAll, beforeAll, test, expect, describe } from "bun:test";
|
||||||
|
|
||||||
|
let baseTime = Date.now();
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
baseTime = Date.now();
|
||||||
|
setSystemTime(new Date(baseTime));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
setSystemTime(); // Reset to real time
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InMemoryCacheDriver", () => {
|
||||||
|
cacheDriverTestSuite(bunTestRunner, {
|
||||||
|
makeCache: () => cacheMemory(),
|
||||||
|
setTime: (ms: number) => {
|
||||||
|
setSystemTime(new Date(baseTime + ms));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evicts least recently used entries by byte size", async () => {
|
||||||
|
// maxSize = 20 bytes for this test
|
||||||
|
const cache = cacheMemory({ maxSize: 20 });
|
||||||
|
// each key and value is 1 char = 1 byte (ASCII)
|
||||||
|
// totals to 2 bytes each
|
||||||
|
await cache.set("a", "1");
|
||||||
|
await cache.set("b", "2");
|
||||||
|
await cache.set("c", "3");
|
||||||
|
await cache.set("d", "4");
|
||||||
|
await cache.set("e", "5");
|
||||||
|
// total: 10 bytes
|
||||||
|
// now add a large value to force eviction
|
||||||
|
await cache.set("big", "1234567890");
|
||||||
|
// should evict least recently used entries until it fits
|
||||||
|
// only "big" and possibly one other small entry should remain
|
||||||
|
expect(await cache.get("big")).toBe("1234567890");
|
||||||
|
// the oldest keys should be evicted
|
||||||
|
expect(await cache.get("a")).toBeUndefined();
|
||||||
|
expect(await cache.get("b")).toBeUndefined();
|
||||||
|
// the most recent small keys may or may not remain depending on eviction order
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws if entry is too large to ever fit", async () => {
|
||||||
|
const cache = cacheMemory({ maxSize: 5 });
|
||||||
|
// key: 3, value: 10 = 13 bytes
|
||||||
|
expect(cache.set("big", "1234567890")).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
123
app/src/core/drivers/cache/in-memory.ts
vendored
Normal file
123
app/src/core/drivers/cache/in-memory.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import type { ICacheDriver } from "./index";
|
||||||
|
|
||||||
|
interface InMemoryCacheOptions {
|
||||||
|
// maximum total size in bytes for all keys and values
|
||||||
|
maxSize?: number;
|
||||||
|
// default time-to-live in seconds
|
||||||
|
defaultTTL?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
value: string;
|
||||||
|
// timestamp in ms, or null for no expiry
|
||||||
|
expiresAt: number | null;
|
||||||
|
// size in bytes of this entry (key + value)
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byteLength(str: string): number {
|
||||||
|
return new TextEncoder().encode(str).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InMemoryCacheDriver implements ICacheDriver {
|
||||||
|
protected cache: Map<string, CacheEntry>;
|
||||||
|
protected maxSize: number;
|
||||||
|
protected defaultTTL: number;
|
||||||
|
protected currentSize: number;
|
||||||
|
|
||||||
|
constructor(options: InMemoryCacheOptions = {}) {
|
||||||
|
this.maxSize = options.maxSize ?? 1024 * 1024 * 10; // 10MB default
|
||||||
|
this.defaultTTL = options.defaultTTL ?? 60 * 60; // 1 hour default
|
||||||
|
this.cache = new Map();
|
||||||
|
this.currentSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected now(): number {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isExpired(entry: CacheEntry): boolean {
|
||||||
|
return entry.expiresAt !== null && entry.expiresAt <= this.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setEntry(key: string, entry: CacheEntry) {
|
||||||
|
const oldEntry = this.cache.get(key);
|
||||||
|
const oldSize = oldEntry ? oldEntry.size : 0;
|
||||||
|
let projectedSize = this.currentSize - oldSize + entry.size;
|
||||||
|
|
||||||
|
// if the entry itself is too large, throw
|
||||||
|
if (entry.size > this.maxSize) {
|
||||||
|
throw new Error(
|
||||||
|
`InMemoryCacheDriver: entry too large (entry: ${entry.size}, max: ${this.maxSize})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// evict LRU until it fits
|
||||||
|
while (projectedSize > this.maxSize && this.cache.size > 0) {
|
||||||
|
// remove least recently used (first inserted)
|
||||||
|
const lruKey = this.cache.keys().next().value;
|
||||||
|
if (typeof lruKey === "string") {
|
||||||
|
const lruEntry = this.cache.get(lruKey);
|
||||||
|
if (lruEntry) {
|
||||||
|
this.currentSize -= lruEntry.size;
|
||||||
|
}
|
||||||
|
this.cache.delete(lruKey);
|
||||||
|
projectedSize = this.currentSize - oldSize + entry.size;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectedSize > this.maxSize) {
|
||||||
|
throw new Error(
|
||||||
|
`InMemoryCacheDriver: maxSize exceeded after eviction (attempted: ${projectedSize}, max: ${this.maxSize})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldEntry) {
|
||||||
|
this.currentSize -= oldSize;
|
||||||
|
}
|
||||||
|
this.cache.delete(key); // Remove to update order (for LRU)
|
||||||
|
this.cache.set(key, entry);
|
||||||
|
this.currentSize += entry.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | undefined> {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return;
|
||||||
|
if (this.isExpired(entry)) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.currentSize -= entry.size;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// mark as recently used
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.cache.set(key, entry);
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: string, ttl?: number): Promise<void> {
|
||||||
|
const expiresAt =
|
||||||
|
ttl === undefined
|
||||||
|
? this.defaultTTL > 0
|
||||||
|
? this.now() + this.defaultTTL * 1000
|
||||||
|
: null
|
||||||
|
: ttl > 0
|
||||||
|
? this.now() + ttl * 1000
|
||||||
|
: null;
|
||||||
|
const size = byteLength(key) + byteLength(value);
|
||||||
|
this.setEntry(key, { value, expiresAt, size });
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (entry) {
|
||||||
|
this.currentSize -= entry.size;
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheMemory = (options?: InMemoryCacheOptions) => {
|
||||||
|
return new InMemoryCacheDriver(options);
|
||||||
|
};
|
||||||
32
app/src/core/drivers/cache/index.ts
vendored
Normal file
32
app/src/core/drivers/cache/index.ts
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Interface for cache driver implementations
|
||||||
|
* Defines standard methods for interacting with a cache storage system
|
||||||
|
*/
|
||||||
|
export interface ICacheDriver {
|
||||||
|
/**
|
||||||
|
* Retrieves a value from the cache by its key
|
||||||
|
*
|
||||||
|
* @param key unique identifier for the cached value
|
||||||
|
* @returns resolves to the cached string value or undefined if not found
|
||||||
|
*/
|
||||||
|
get(key: string): Promise<string | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a value in the cache with an optional time-to-live
|
||||||
|
*
|
||||||
|
* @param key unique identifier for storing the value
|
||||||
|
* @param value string value to cache
|
||||||
|
* @param ttl optional time-to-live in seconds before the value expires
|
||||||
|
* @throws if the value cannot be stored
|
||||||
|
*/
|
||||||
|
set(key: string, value: string, ttl?: number): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a value from the cache
|
||||||
|
*
|
||||||
|
* @param key unique identifier of the value to delete
|
||||||
|
*/
|
||||||
|
del(key: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cacheDriverTestSuite } from "./cache-driver-test-suite";
|
||||||
13
app/src/core/drivers/email/index.ts
Normal file
13
app/src/core/drivers/email/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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>>;
|
||||||
|
}
|
||||||
116
app/src/core/drivers/email/mailchannels.ts
Normal file
116
app/src/core/drivers/email/mailchannels.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { mergeObject, type RecursivePartial } from "core/utils";
|
||||||
|
import type { IEmailDriver } from "./index";
|
||||||
|
|
||||||
|
export type MailchannelsEmailOptions = {
|
||||||
|
apiKey: string;
|
||||||
|
host?: string;
|
||||||
|
from?: { email: string; name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Recipient = {
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MailchannelsSendOptions = RecursivePartial<{
|
||||||
|
attachments: Array<{
|
||||||
|
content: string;
|
||||||
|
filename: string;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
campaign_id: string;
|
||||||
|
content: Array<{
|
||||||
|
template_type?: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
}>;
|
||||||
|
dkim_domain: string;
|
||||||
|
dkim_private_key: string;
|
||||||
|
dkim_selector: string;
|
||||||
|
from: Recipient;
|
||||||
|
headers: {};
|
||||||
|
personalizations: Array<{
|
||||||
|
bcc: Array<Recipient>;
|
||||||
|
cc: Array<Recipient>;
|
||||||
|
dkim_domain: string;
|
||||||
|
dkim_private_key: string;
|
||||||
|
dkim_selector: string;
|
||||||
|
dynamic_template_data: {};
|
||||||
|
from: Recipient;
|
||||||
|
headers: {};
|
||||||
|
reply_to: Recipient;
|
||||||
|
subject: string;
|
||||||
|
to: Array<Recipient>;
|
||||||
|
}>;
|
||||||
|
reply_to: Recipient;
|
||||||
|
subject: string;
|
||||||
|
tracking_settings: {
|
||||||
|
click_tracking: {
|
||||||
|
enable: boolean;
|
||||||
|
};
|
||||||
|
open_tracking: {
|
||||||
|
enable: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
transactional: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type MailchannelsEmailResponse = {
|
||||||
|
request_id: string;
|
||||||
|
results: Array<{
|
||||||
|
index: number;
|
||||||
|
message_id: string;
|
||||||
|
reason: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mailchannelsEmail = (
|
||||||
|
config: MailchannelsEmailOptions,
|
||||||
|
): IEmailDriver<MailchannelsEmailResponse, MailchannelsSendOptions> => {
|
||||||
|
const host = config.host ?? "https://api.mailchannels.net/tx/v1/send";
|
||||||
|
const from = config.from ?? { email: "onboarding@mailchannels.net", name: "Mailchannels" };
|
||||||
|
return {
|
||||||
|
send: async (
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
body: string | { text: string; html: string },
|
||||||
|
options?: MailchannelsSendOptions,
|
||||||
|
) => {
|
||||||
|
const payload: MailchannelsSendOptions = mergeObject(
|
||||||
|
{
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
content:
|
||||||
|
typeof body === "string"
|
||||||
|
? [{ type: "text/html", value: body }]
|
||||||
|
: [
|
||||||
|
{ type: "text/plain", value: body.text },
|
||||||
|
{ type: "text/html", value: body.html },
|
||||||
|
],
|
||||||
|
personalizations: [
|
||||||
|
{
|
||||||
|
to: [{ email: to }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await fetch(host, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Api-Key": config.apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...payload, ...options }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as MailchannelsEmailResponse;
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
72
app/src/core/drivers/email/resend.ts
Normal file
72
app/src/core/drivers/email/resend.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { IEmailDriver } from "./index";
|
||||||
|
|
||||||
|
export type ResendEmailOptions = {
|
||||||
|
apiKey: string;
|
||||||
|
host?: string;
|
||||||
|
from?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResendEmailSendOptions = {
|
||||||
|
bcc?: string | string[];
|
||||||
|
cc?: string | string[];
|
||||||
|
reply_to?: string | string[];
|
||||||
|
scheduled_at?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
attachments?: {
|
||||||
|
content: Buffer | string;
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
content_type: string;
|
||||||
|
}[];
|
||||||
|
tags?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResendEmailResponse = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resendEmail = (
|
||||||
|
config: ResendEmailOptions,
|
||||||
|
): IEmailDriver<ResendEmailResponse, ResendEmailSendOptions> => {
|
||||||
|
const host = config.host ?? "https://api.resend.com/emails";
|
||||||
|
const from = config.from ?? "Acme <onboarding@resend.dev>";
|
||||||
|
return {
|
||||||
|
send: async (
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
body: string | { text: string; html: string },
|
||||||
|
options?: ResendEmailSendOptions,
|
||||||
|
) => {
|
||||||
|
const payload: any = {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof body === "string") {
|
||||||
|
payload.html = body;
|
||||||
|
} else {
|
||||||
|
payload.html = body.html;
|
||||||
|
payload.text = body.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(host, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...payload, ...options }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as ResendEmailResponse;
|
||||||
|
return { success: true, data };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
89
app/src/core/drivers/email/ses.ts
Normal file
89
app/src/core/drivers/email/ses.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { IEmailDriver } from "./index";
|
||||||
|
import { AwsClient } from "aws4fetch";
|
||||||
|
|
||||||
|
export type SesEmailOptions = {
|
||||||
|
region: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SesSendOptions = {
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
replyTo?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SesEmailResponse = {
|
||||||
|
MessageId?: string;
|
||||||
|
status: number;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sesEmail = (
|
||||||
|
config: SesEmailOptions,
|
||||||
|
): IEmailDriver<SesEmailResponse, SesSendOptions> => {
|
||||||
|
const endpoint = `https://email.${config.region}.amazonaws.com/`;
|
||||||
|
const from = config.from;
|
||||||
|
const aws = new AwsClient({
|
||||||
|
accessKeyId: config.accessKeyId,
|
||||||
|
secretAccessKey: config.secretAccessKey,
|
||||||
|
service: "ses",
|
||||||
|
region: config.region,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
send: async (
|
||||||
|
to: string,
|
||||||
|
subject: string,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
if (typeof body === "string") {
|
||||||
|
params["Message.Body.Html.Data"] = body;
|
||||||
|
} else {
|
||||||
|
params["Message.Body.Html.Data"] = body.html;
|
||||||
|
params["Message.Body.Text.Data"] = body.text;
|
||||||
|
}
|
||||||
|
if (options?.cc) {
|
||||||
|
options.cc.forEach((cc, i) => {
|
||||||
|
params[`Destination.CcAddresses.member.${i + 1}`] = cc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options?.bcc) {
|
||||||
|
options.bcc.forEach((bcc, i) => {
|
||||||
|
params[`Destination.BccAddresses.member.${i + 1}`] = bcc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (options?.replyTo) {
|
||||||
|
options.replyTo.forEach((reply, i) => {
|
||||||
|
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, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formBody,
|
||||||
|
});
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
5
app/src/core/drivers/index.ts
Normal file
5
app/src/core/drivers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type { ICacheDriver } from "./cache";
|
||||||
|
export { cacheMemory } from "./cache/in-memory";
|
||||||
|
|
||||||
|
export type { IEmailDriver } from "./email";
|
||||||
|
export { resendEmail } from "./email/resend";
|
||||||
2
bun.lock
2
bun.lock
@@ -15,7 +15,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "bknd",
|
"name": "bknd",
|
||||||
"version": "0.14.0-rc.2",
|
"version": "0.15.0-rc.2",
|
||||||
"bin": "./dist/cli/index.js",
|
"bin": "./dist/cli/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user