UNPKG

@alepha/server-cache

Version:

Adds ETag and Cache-Control headers to server responses.

168 lines (165 loc) 5.16 kB
import { $cache, AlephaCache } from "@alepha/cache"; import { $hook, $inject, $module, Alepha } from "@alepha/core"; import { createHash } from "node:crypto"; import { DateTimeProvider } from "@alepha/datetime"; import { $logger } from "@alepha/logger"; import { ActionDescriptor } from "@alepha/server"; //#region src/providers/ServerCacheProvider.ts ActionDescriptor.prototype.invalidate = async function() { await this.alepha.inject(ServerCacheProvider).invalidate(this.route); }; var ServerCacheProvider = class { log = $logger(); alepha = $inject(Alepha); time = $inject(DateTimeProvider); cache = $cache({ provider: "memory" }); generateETag(content) { return `"${createHash("md5").update(content).digest("hex")}"`; } async invalidate(route) { const cache = route.cache; if (!cache) return; await this.cache.invalidate(this.createCacheKey(route)); } onActionRequest = $hook({ on: "action:onRequest", handler: async ({ action, request }) => { const cache = action.route.cache; if (!cache) return; const key = this.createCacheKey(action.route, request); const cached = await this.cache.get(key); if (cached) { const body = cached.contentType === "application/json" ? JSON.parse(cached.body) : cached.body; this.log.trace("Cache hit for action", { key, action: action.name }); request.reply.body = body; } else this.log.trace("Cache miss for action", { key, action: action.name }); } }); onActionResponse = $hook({ on: "action:onResponse", handler: async ({ action, request, response }) => { const cache = action.route.cache; if (!cache || !response) return; const key = this.createCacheKey(action.route, request); const contentType = typeof response === "string" ? "text/plain" : "application/json"; const body = contentType === "text/plain" ? response : JSON.stringify(response); const etag = this.generateETag(body); this.log.trace("Caching action", { key, action: action.name, length: body.length }); await this.cache.set(key, { body, lastModified: this.time.toISOString(), contentType, hash: etag }); } }); onRequest = $hook({ on: "server:onRequest", handler: async ({ route, request }) => { const cache = route.cache; if (!cache) return; const key = this.createCacheKey(route, request); const cached = await this.cache.get(key); if (cached) { this.log.trace("Cache hit for route", { key, route: route.path }); if (request.headers["if-none-match"] === cached.hash || request.headers["if-modified-since"] === cached.lastModified) { request.reply.status = 304; return; } request.reply.body = cached.body; request.reply.status = cached.status ?? 200; if (cached.contentType) request.reply.setHeader("Content-Type", cached.contentType); } else this.log.trace("Cache miss for route", { key, route: route.path }); } }); onResponse = $hook({ on: "server:onResponse", priority: "first", handler: async ({ route, request, response }) => { const cache = route.cache; if (!cache) return; const key = this.createCacheKey(route, request); if (typeof response.body === "string") { this.log.trace("Caching response", { key, route: route.path, status: response.status }); const etag = this.generateETag(response.body); await this.cache.set(key, { body: response.body, status: response.status, contentType: response.headers?.["content-type"], lastModified: this.time.toISOString(), hash: etag }); response.headers ??= {}; response.headers.etag = etag; } } }); getCacheOptions(cache) { if (typeof cache === "boolean") return { ttl: this.time.duration(5, "minutes") }; if (this.time.isDurationLike(cache)) return { ttl: cache }; return { ...cache }; } createCacheKey(route, config) { const params = []; for (const [key, value] of Object.entries(config?.params ?? {})) params.push(`${key}=${value}`); for (const [key, value] of Object.entries(config?.query ?? {})) params.push(`${key}=${value}`); return `${route.method}:${route.path.replaceAll(":", "")}:${params.join(",").replaceAll(":", "")}`; } }; //#endregion //#region src/index.ts /** * Plugin for Alepha Server that provides server-side caching capabilities. * It uses the Alepha Cache module to cache responses from server actions ($action). * It also provides a ETag-based cache invalidation mechanism. * * @example * ```ts * import { Alepha } from "alepha"; * import { $action } from "alepha/server"; * import { AlephaServerCache } from "alepha/server/cache"; * * class ApiServer { * hello = $action({ * cache: true, * handler: () => "Hello, World!", * }); * } * * const alepha = Alepha.create() * .with(AlephaServerCache) * .with(ApiServer); * * run(alepha); * ``` * * @see {@link ServerCacheProvider} * @module alepha.server.cache */ const AlephaServerCache = $module({ name: "alepha.server.cache", services: [AlephaCache, ServerCacheProvider] }); //#endregion export { AlephaServerCache, ServerCacheProvider }; //# sourceMappingURL=index.js.map