UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

269 lines (225 loc) 6.79 kB
import { $hook, $inject, Alepha, AlephaError } from "alepha"; import { $logger } from "alepha/logger"; import type { CachePrimitive } from "../primitives/$cache.ts"; import { CacheProvider } from "./CacheProvider.ts"; // --------------------------------------------------------------------------------------------------------------------- /** * KVNamespace interface matching Cloudflare's KV API. */ export interface KVNamespace { get(key: string, type: "arrayBuffer"): Promise<ArrayBuffer | null>; get(key: string, type: "text"): Promise<string | null>; get(key: string, type?: "text"): Promise<string | null>; put( key: string, value: string | ArrayBuffer | ReadableStream, options?: KVPutOptions, ): Promise<void>; delete(key: string): Promise<void>; list(options?: KVListOptions): Promise<KVListResult>; } export interface KVPutOptions { expiration?: number; expirationTtl?: number; metadata?: unknown; } export interface KVListOptions { prefix?: string; limit?: number; cursor?: string; } export interface KVListResult { keys: KVListKey[]; list_complete: boolean; cursor?: string; } export interface KVListKey { name: string; expiration?: number; metadata?: unknown; } // --------------------------------------------------------------------------------------------------------------------- /** * Default KV binding name used in wrangler configuration. */ export const KV_DEFAULT_BINDING = "KV_CACHE"; // --------------------------------------------------------------------------------------------------------------------- /** * Cloudflare KV cache provider. * * Uses a KV namespace binding for all cache operations. * Keys are stored as: `cache:{name}:{key}` * * **Required Cloudflare binding:** * - `KV_CACHE` - A KV namespace binding in wrangler configuration * * @example * ```toml * # wrangler.toml - automatically generated by alepha build * [[kv_namespaces]] * binding = "KV_CACHE" * id = "abc123" * ``` */ export class CloudflareKVProvider extends CacheProvider { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected kv?: KVNamespace; protected readonly onStart = $hook({ on: "start", handler: async () => { const caches = this.alepha .primitives<CachePrimitive>("cache") .filter((it) => it.provider === this); if (caches.length === 0) { this.log.info( "CloudflareKVProvider is registered but no cache primitives are using it. Skipping KV initialization.", ); return; } const cloudflareEnv = this.alepha.get("cloudflare.env") as | Record<string, unknown> | undefined; if (!cloudflareEnv) { throw new AlephaError( "Cloudflare Workers environment not found in Alepha store under 'cloudflare.env'.", ); } const binding = cloudflareEnv[KV_DEFAULT_BINDING] as | KVNamespace | undefined; if (!binding) { throw new AlephaError( `KV binding '${KV_DEFAULT_BINDING}' not found in Cloudflare Workers environment.`, ); } this.kv = binding; this.log.info("Cloudflare KV cache OK"); }, }); public async get(name: string, key: string): Promise<Uint8Array | undefined> { if (!this.alepha.isStarted()) { return; } const kvKey = this.prefix(name, key); const buffer = await this.getKV().get(kvKey, "arrayBuffer"); if (!buffer) { return; } this.log.debug("Cache hit", { size: buffer.byteLength, key: kvKey, }); return new Uint8Array(buffer); } public async set( name: string, key: string, value: Uint8Array, ttl?: number, ): Promise<Uint8Array> { if (!this.alepha.isReady()) { return new Uint8Array(value); } const kvKey = this.prefix(name, key); const options: KVPutOptions = {}; if (ttl) { // KV expects TTL in seconds, we receive milliseconds options.expirationTtl = Math.max(60, Math.ceil(ttl / 1000)); } await this.getKV().put( kvKey, value.buffer.slice( value.byteOffset, value.byteOffset + value.byteLength, ) as ArrayBuffer, options, ); return value; } public async del(name: string, ...keys: string[]): Promise<void> { const kv = this.getKV(); if (keys.length === 0) { // Delete all keys under this cache name const prefix = this.prefix(name); const allKeys = await this.listAllKeys(`${prefix}:`); for (const k of allKeys) { await kv.delete(k); } return; } const nameKey = this.prefix(name); for (const key of keys) { const fullKey = key.startsWith(nameKey) ? key : this.prefix(name, key); await kv.delete(fullKey); } } public async has(name: string, key: string): Promise<boolean> { const kvKey = this.prefix(name, key); const value = await this.getKV().get(kvKey, "text"); return value !== null; } public async keys(name: string, filter?: string): Promise<string[]> { const prefix = filter ? `${this.prefix(name)}:${filter}` : `${this.prefix(name)}:`; return this.listAllKeys(prefix); } public async clear(): Promise<void> { this.log.debug("Clearing all cache"); const kv = this.getKV(); const allKeys = await this.listAllKeys("cache:"); for (const key of allKeys) { await kv.delete(key); } } public async incr( name: string, key: string, amount: number, ): Promise<number> { const kvKey = this.prefix(name, key); const kv = this.getKV(); const existing = await kv.get(kvKey, "text"); let current = 0; if (existing !== null) { current = Number.parseInt(existing, 10) || 0; } const newValue = current + amount; await kv.put(kvKey, String(newValue)); return newValue; } /** * Build the full KV key: `cache:{name}:{key}` */ protected prefix(...path: string[]): string { return ["cache", ...path].join(":"); } protected getKV(): KVNamespace { if (!this.kv) { throw new AlephaError( "KV namespace not initialized. Call start() first.", ); } return this.kv; } /** * List all keys matching a prefix, handling pagination. */ protected async listAllKeys(prefix: string): Promise<string[]> { const kv = this.getKV(); const allKeys: string[] = []; let cursor: string | undefined; do { const result = await kv.list({ prefix, cursor, }); for (const key of result.keys) { allKeys.push(key.name); } cursor = result.list_complete ? undefined : result.cursor; } while (cursor); return allKeys; } }