UNPKG

alepha

Version:

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

347 lines (346 loc) 11.7 kB
import { $hook, $inject, $module, Alepha, createMiddleware } from "alepha"; import { $cache, AlephaCache } from "alepha/cache"; import { AlephaCrypto, CryptoProvider } from "alepha/crypto"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; //#region ../../src/server/etag/providers/ServerEtagProvider.ts var ServerEtagProvider = class { log = $logger(); alepha = $inject(Alepha); crypto = $inject(CryptoProvider); time = $inject(DateTimeProvider); cache = $cache({ provider: "memory", name: "http:server" }); generateETag(content) { const data = typeof content === "string" ? content : content.toString(); return `"${this.crypto.hash(data, "md5")}"`; } async invalidate(route) { await this.cache.invalidate(this.createCacheKey(route)); } /** * Check cache for a stored response. Returns the cached body if found, undefined otherwise. * Called by the $etag() middleware before the handler runs. */ async checkCache(request, options) { if (!this.shouldStore(options)) return; const actionRequest = this.alepha.store.get("alepha.action.request"); const keySource = { method: request.metadata?.routeMethod ?? actionRequest?.method ?? request.method ?? "GET", path: request.metadata?.routePath ?? String(actionRequest?.url ?? request.url ?? "") }; const key = this.createCacheKey(keySource, actionRequest ?? request); const cached = await this.cache.get(key); if (!cached) { this.log.trace("Cache miss", { key }); return; } this.log.trace("Cache hit", { key }); request.metadata ??= {}; request.metadata.etagHit = true; if (request.reply) { if (request.headers?.["if-none-match"] === cached.hash || request.headers?.["if-modified-since"] === cached.lastModified) { request.reply.status = 304; request.reply.setHeader("etag", cached.hash); request.reply.setHeader("last-modified", cached.lastModified); this.log.trace("ETag match, returning 304", { key, etag: cached.hash }); return request.reply.body; } request.reply.body = cached.body; request.reply.status = cached.status ?? 200; if (cached.contentType) request.reply.setHeader("Content-Type", cached.contentType); request.reply.setHeader("etag", cached.hash); request.reply.setHeader("last-modified", cached.lastModified); } return cached.contentType === "application/json" ? JSON.parse(cached.body) : cached.body; } /** * After an action response, store it in cache if store is enabled. */ onActionResponse = $hook({ on: "action:onResponse", handler: async ({ action, request, response }) => { const options = request.metadata?.etagOptions; if (!options || !this.shouldStore(options)) return; if (request.metadata?.etagHit) return; if (request.reply.status && request.reply.status >= 400) return; if (!response) return; const contentType = typeof response === "string" ? "text/plain" : "application/json"; const body = contentType === "text/plain" ? response : JSON.stringify(response); const generatedEtag = this.generateETag(body); const lastModified = this.time.toISOString(); const key = this.createCacheKey(action.route, request); this.log.trace("Storing action response", { key, action: action.name }); await this.cache.set(key, { body, lastModified, contentType, hash: generatedEtag }); const cacheControl = this.buildCacheControlHeader(options); if (cacheControl) request.reply.setHeader("cache-control", cacheControl); } }); /** * Before sending the response, check ETag for etag-only routes. * This handles the case where etag is enabled but store is not. */ onSend = $hook({ on: "server:onSend", handler: ({ request }) => { const options = request.metadata?.etagOptions; if (!options) return; const shouldStore = this.shouldStore(options); const shouldUseEtag = this.shouldUseEtag(options); if (request.reply.headers.etag) return; if (!shouldStore && shouldUseEtag && request.reply.body != null && (typeof request.reply.body === "string" || Buffer.isBuffer(request.reply.body))) { const generatedEtag = this.generateETag(request.reply.body); if (request.headers["if-none-match"] === generatedEtag) { request.reply.status = 304; request.reply.body = void 0; request.reply.setHeader("etag", generatedEtag); this.log.trace("ETag match on send, returning 304", { route: request.url, etag: generatedEtag }); return; } } } }); /** * After the response is generated, store it and set ETag headers. */ onResponse = $hook({ on: "server:onResponse", priority: "first", handler: async ({ route, request, response }) => { const options = request.metadata?.etagOptions; if (!options) return; const cacheControl = this.buildCacheControlHeader(options); if (cacheControl) response.headers["cache-control"] = cacheControl; const shouldStore = this.shouldStore(options); const shouldUseEtag = this.shouldUseEtag(options); if (!shouldStore && !shouldUseEtag) return; if (request.metadata?.etagHit) return; if (response.status && response.status >= 400) return; response.headers ??= {}; const key = this.createCacheKey(route, request); if (response.body instanceof ReadableStream && shouldStore) { const [clientStream, cacheStream] = response.body.tee(); response.body = clientStream; this.collectStreamForCache(cacheStream, key, response.status, response.headers?.["content-type"], shouldUseEtag).then((hash) => { if (shouldUseEtag && hash) this.log.trace("Stream cached with hash", { key, hash }); }).catch((err) => { this.log.warn("Failed to cache stream", { key, error: err }); }); return; } if (typeof response.body !== "string") return; const generatedEtag = this.generateETag(response.body); const lastModified = this.time.toISOString(); if (shouldStore) { this.log.trace("Storing response", { key, route: route.path, etag: shouldUseEtag }); await this.cache.set(key, { body: response.body, status: response.status, contentType: response.headers?.["content-type"], lastModified, hash: generatedEtag }); } if (shouldUseEtag) { response.headers.etag = generatedEtag; response.headers["last-modified"] = lastModified; } } }); buildCacheControlHeader(options) { if (!options) return; const control = options.control; if (!control) return; if (typeof control === "string") return control; if (control === true) return "public, max-age=300"; const directives = []; if (control.public) directives.push("public"); if (control.private) directives.push("private"); if (control.noCache) directives.push("no-cache"); if (control.noStore) directives.push("no-store"); if (control.maxAge !== void 0) { const maxAgeSeconds = this.durationToSeconds(control.maxAge); directives.push(`max-age=${maxAgeSeconds}`); } if (control.sMaxAge !== void 0) { const sMaxAgeSeconds = this.durationToSeconds(control.sMaxAge); directives.push(`s-maxage=${sMaxAgeSeconds}`); } if (control.mustRevalidate) directives.push("must-revalidate"); if (control.proxyRevalidate) directives.push("proxy-revalidate"); if (control.immutable) directives.push("immutable"); if (control.staleWhileRevalidate !== void 0) { const seconds = this.durationToSeconds(control.staleWhileRevalidate); directives.push(`stale-while-revalidate=${seconds}`); } return directives.length > 0 ? directives.join(", ") : void 0; } shouldStore(options) { if (!options) return false; if (options.store) return true; return false; } shouldUseEtag(options) { if (!options) return false; if (options.etag) return true; return false; } durationToSeconds(duration) { if (typeof duration === "number") return duration; return this.time.duration(duration).asSeconds(); } 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(":", "")}`; } /** * Collect a ReadableStream into a string and store it in the cache. * This runs in the background while the original stream is sent to the client. */ async collectStreamForCache(stream, key, status, contentType, generateEtag) { const chunks = []; const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } const decoder = new TextDecoder(); const body = chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join("") + decoder.decode(); const hash = this.generateETag(body); const lastModified = this.time.toISOString(); this.log.trace("Storing streamed response", { key }); await this.cache.set(key, { body, status, contentType, lastModified, hash }); return generateEtag ? hash : void 0; } finally { reader.releaseLock(); } } }; //#endregion //#region ../../src/server/etag/primitives/$etag.ts /** * Middleware that enables ETag-based response caching per-route. * * Sets per-request etag options in the ALS context. * The global `ServerEtagProvider` hooks read these * to generate ETags, handle 304s, and optionally store responses. * * When `store` is enabled, the middleware also checks the cache before * calling the handler, short-circuiting on cache hits. * * **Route middleware** — works inside `$action`, `$page`, or any pipeline. * * ```typescript * class UserController { * // ETag only (no response caching) * getUser = $action({ * use: [$etag()], * handler: async ({ params }) => { ... }, * }); * * // ETag + response caching (store) * getProfile = $action({ * use: [$etag(true)], * handler: async ({ params }) => { ... }, * }); * * // Fine-grained control * getStats = $action({ * use: [$etag({ store: { ttl: [5, "minutes"] }, control: { public: true, maxAge: 300 } })], * handler: async ({ params }) => { ... }, * }); * } * ``` */ const $etag = (options) => { const resolved = resolveEtagOptions(options); return createMiddleware({ name: "$etag", options: resolved, handler: ({ alepha, next }) => { const etagProvider = alepha.inject(ServerEtagProvider); return async (...args) => { const request = alepha.get("alepha.http.request") ?? args[0]; if (request?.metadata) request.metadata.etagOptions = resolved; if (etagProvider.shouldStore(resolved)) { if (request) { const cached = await etagProvider.checkCache(request, resolved); if (cached !== void 0 || request.metadata?.etagHit) return cached; } } return next(...args); }; } }); }; function resolveEtagOptions(options) { if (options === true) return { store: true, etag: true }; if (!options) return { etag: true }; return { etag: true, ...options }; } //#endregion //#region ../../src/server/etag/index.ts /** * ETag-based response caching. * * **Features:** * - ETag generation and validation * - Conditional request handling (304 Not Modified) * - Optional response caching (store) * - Cache-Control header support * * @module alepha.server.etag */ const AlephaServerEtag = $module({ name: "alepha.server.etag", services: [ AlephaCache, AlephaCrypto, ServerEtagProvider ] }); //#endregion export { $etag, AlephaServerEtag, ServerEtagProvider, resolveEtagOptions }; //# sourceMappingURL=index.js.map