alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
269 lines (225 loc) • 6.79 kB
text/typescript
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;
}
}