diff --git a/app/src/App.ts b/app/src/App.ts index b1f475f..c12cdf3 100644 --- a/app/src/App.ts +++ b/app/src/App.ts @@ -17,6 +17,7 @@ import { AdminController, type AdminControllerOptions } from "modules/server/Adm import { SystemController } from "modules/server/SystemController"; import type { MaybePromise } from "core/types"; import type { ServerEnv } from "modules/Controller"; +import type { IEmailDriver, ICacheDriver } from "core/drivers"; // biome-ignore format: must be here import { Api, type ApiOptions } from "Api"; @@ -61,6 +62,10 @@ export type AppOptions = { seed?: (ctx: ModuleBuildContext & { app: App }) => Promise; manager?: Omit; asyncEventsMode?: "sync" | "async" | "none"; + drivers?: { + email?: IEmailDriver; + cache?: ICacheDriver; + }; }; export type CreateAppConfig = { connection?: Connection | { url: string }; diff --git a/app/src/adapter/cloudflare/drivers/cache.ts b/app/src/adapter/cloudflare/drivers/cache.ts new file mode 100644 index 0000000..329d407 --- /dev/null +++ b/app/src/adapter/cloudflare/drivers/cache.ts @@ -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 { + const value = await this.kv.get(this.getKey(key)); + return value === null ? undefined : value; + } + + async set(key: string, value: string, ttl?: number): Promise { + 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 { + await this.kv.delete(this.getKey(key)); + } +} + +export const cacheWorkersKV = (kv: KVNamespace, options?: WorkersKVCacheOptions) => { + return new WorkersKVCacheDriver(kv, options); +}; diff --git a/app/src/adapter/cloudflare/drivers/cache.vitest.ts b/app/src/adapter/cloudflare/drivers/cache.vitest.ts new file mode 100644 index 0000000..d9a856d --- /dev/null +++ b/app/src/adapter/cloudflare/drivers/cache.vitest.ts @@ -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, + }, + }); +}); diff --git a/app/src/core/drivers/cache/cache-driver-test-suite.ts b/app/src/core/drivers/cache/cache-driver-test-suite.ts new file mode 100644 index 0000000..ce38d56 --- /dev/null +++ b/app/src/core/drivers/cache/cache-driver-test-suite.ts @@ -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"); + }); +} diff --git a/app/src/core/drivers/cache/in-memory.spec.ts b/app/src/core/drivers/cache/in-memory.spec.ts new file mode 100644 index 0000000..af1486e --- /dev/null +++ b/app/src/core/drivers/cache/in-memory.spec.ts @@ -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(); + }); +}); diff --git a/app/src/core/drivers/cache/in-memory.ts b/app/src/core/drivers/cache/in-memory.ts new file mode 100644 index 0000000..45aaece --- /dev/null +++ b/app/src/core/drivers/cache/in-memory.ts @@ -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; + 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 { + 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 { + 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 { + 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); +}; diff --git a/app/src/core/drivers/cache/index.ts b/app/src/core/drivers/cache/index.ts new file mode 100644 index 0000000..51c104d --- /dev/null +++ b/app/src/core/drivers/cache/index.ts @@ -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; + + /** + * 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; + + /** + * Removes a value from the cache + * + * @param key unique identifier of the value to delete + */ + del(key: string): Promise; +} + +export { cacheDriverTestSuite } from "./cache-driver-test-suite"; diff --git a/app/src/core/drivers/email/index.ts b/app/src/core/drivers/email/index.ts new file mode 100644 index 0000000..646942c --- /dev/null +++ b/app/src/core/drivers/email/index.ts @@ -0,0 +1,13 @@ +export type TEmailResponse = { + success: boolean; + data?: Data; +}; + +export interface IEmailDriver { + send( + to: string, + subject: string, + body: string | { text: string; html: string }, + options?: Options, + ): Promise>; +} diff --git a/app/src/core/drivers/email/mailchannels.ts b/app/src/core/drivers/email/mailchannels.ts new file mode 100644 index 0000000..1df7968 --- /dev/null +++ b/app/src/core/drivers/email/mailchannels.ts @@ -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; + cc: Array; + dkim_domain: string; + dkim_private_key: string; + dkim_selector: string; + dynamic_template_data: {}; + from: Recipient; + headers: {}; + reply_to: Recipient; + subject: string; + to: Array; + }>; + 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 => { + 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 }; + }, + }; +}; diff --git a/app/src/core/drivers/email/resend.ts b/app/src/core/drivers/email/resend.ts new file mode 100644 index 0000000..8cd79c9 --- /dev/null +++ b/app/src/core/drivers/email/resend.ts @@ -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; + 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 => { + const host = config.host ?? "https://api.resend.com/emails"; + const from = config.from ?? "Acme "; + 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 }; + }, + }; +}; diff --git a/app/src/core/drivers/email/ses.ts b/app/src/core/drivers/email/ses.ts new file mode 100644 index 0000000..8748b66 --- /dev/null +++ b/app/src/core/drivers/email/ses.ts @@ -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 => { + 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 = { + 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>/); + if (match) MessageId = match[1]; + return { + success: res.ok, + data: { MessageId, status: res.status, body: text }, + }; + }, + }; +}; diff --git a/app/src/core/drivers/index.ts b/app/src/core/drivers/index.ts new file mode 100644 index 0000000..a587df1 --- /dev/null +++ b/app/src/core/drivers/index.ts @@ -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"; diff --git a/bun.lock b/bun.lock index b992987..7a37352 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "app": { "name": "bknd", - "version": "0.14.0-rc.2", + "version": "0.15.0-rc.2", "bin": "./dist/cli/index.js", "dependencies": { "@cfworker/json-schema": "^4.1.1",