UNPKG

alepha

Version:

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

476 lines (401 loc) 14.4 kB
import { $hook, $inject, Alepha } from "alepha"; import { $cache } from "alepha/cache"; import { CryptoProvider } from "alepha/crypto"; import { DateTimeProvider, type DurationLike } from "alepha/datetime"; import { $logger } from "alepha/logger"; import type { ServerRequest, ServerRoute } from "alepha/server"; import type { EtagMiddlewareOptionsResolved } from "../primitives/$etag.ts"; // --------------------------------------------------------------------------------------------------------------------- export class ServerEtagProvider { protected readonly log = $logger(); protected readonly alepha = $inject(Alepha); protected readonly crypto = $inject(CryptoProvider); protected readonly time = $inject(DateTimeProvider); protected readonly cache = $cache<RouteCacheEntry>({ provider: "memory", name: "http:server", }); public generateETag(content: string | Buffer): string { const data = typeof content === "string" ? content : content.toString(); return `"${this.crypto.hash(data, "md5")}"`; } public async invalidate(route: ServerRoute) { 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. */ public async checkCache( request: ServerRequest, options: EtagMiddlewareOptionsResolved, ): Promise<any> { if (!this.shouldStore(options)) { return undefined; } // Build key from route metadata (set by ServerRouterProvider before handler) // or from action request context (for .run() direct calls) 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 ?? ""), } as ServerRoute; const key = this.createCacheKey(keySource, actionRequest ?? request); const cached = await this.cache.get(key); if (!cached) { this.log.trace("Cache miss", { key }); return undefined; } this.log.trace("Cache hit", { key }); // Mark as cache hit in request metadata request.metadata ??= {} as any; request.metadata.etagHit = true; // For HTTP routes, set reply headers if (request.reply) { // Check if client has matching ETag - return 304 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); } // For action direct invocations, return body const body = cached.contentType === "application/json" ? JSON.parse(cached.body) : cached.body; return body; } // ------------------------------------------------------------------------------------------------------------------- // Hooks (post-handler, read from request.metadata) // ------------------------------------------------------------------------------------------------------------------- /** * After an action response, store it in cache if store is enabled. */ protected readonly onActionResponse = $hook({ on: "action:onResponse", handler: async ({ action, request, response }) => { const options = request.metadata ?.etagOptions as EtagMiddlewareOptionsResolved; if (!options || !this.shouldStore(options)) { return; } // Skip if this was a cache hit (don't re-store) if (request.metadata?.etagHit) { return; } // Don't cache error responses (status >= 400) 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: body, lastModified, contentType: contentType, hash: generatedEtag, }); // Set Cache-Control header if configured 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. */ protected readonly onSend = $hook({ on: "server:onSend", handler: ({ request }) => { const options = request.metadata ?.etagOptions as EtagMiddlewareOptionsResolved; if (!options) { return; } const shouldStore = this.shouldStore(options); const shouldUseEtag = this.shouldUseEtag(options); if (request.reply.headers.etag) { // ETag already set, skip 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 = undefined; 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. */ protected readonly onResponse = $hook({ on: "server:onResponse", priority: "first", handler: async ({ route, request, response }) => { const options = request.metadata ?.etagOptions as EtagMiddlewareOptionsResolved; if (!options) { return; } // Set Cache-Control header if configured const cacheControl = this.buildCacheControlHeader(options); if (cacheControl) { response.headers["cache-control"] = cacheControl; } const shouldStore = this.shouldStore(options); const shouldUseEtag = this.shouldUseEtag(options); // Skip if neither cache nor etag is enabled if (!shouldStore && !shouldUseEtag) { return; } // Skip if this was a cache hit (don't re-store) if (request.metadata?.etagHit) { return; } // Don't cache error responses (status >= 400) if (response.status && response.status >= 400) { return; } // Initialize headers if not present response.headers ??= {}; const key = this.createCacheKey(route, request); // Handle ReadableStream responses (e.g., SSR streaming) if (response.body instanceof ReadableStream && shouldStore) { // Tee the stream: one for client, one for cache collection const [clientStream, cacheStream] = ( response.body as ReadableStream<Uint8Array> ).tee(); // Replace response body with client stream (continues streaming to client) response.body = clientStream as typeof response.body; // Collect cache stream in background (non-blocking) 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; } // Only process string responses (text, html, json, etc.) if (typeof response.body !== "string") { return; } const generatedEtag = this.generateETag(response.body); const lastModified = this.time.toISOString(); // Store response if storing is enabled 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, }); } // Set ETag headers if etag is enabled if (shouldUseEtag) { response.headers.etag = generatedEtag; response.headers["last-modified"] = lastModified; } }, }); // ------------------------------------------------------------------------------------------------------------------- // Public helpers // ------------------------------------------------------------------------------------------------------------------- public buildCacheControlHeader( options?: EtagMiddlewareOptionsResolved, ): string | undefined { if (!options) { return undefined; } const control = options.control; if (!control) { return undefined; } // If control is a string, return it directly if (typeof control === "string") { return control; } // If control is true, return default Cache-Control if (control === true) { return "public, max-age=300"; } // Build Cache-Control from object directives const directives: string[] = []; 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 !== undefined) { const maxAgeSeconds = this.durationToSeconds(control.maxAge); directives.push(`max-age=${maxAgeSeconds}`); } if (control.sMaxAge !== undefined) { 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 !== undefined) { const seconds = this.durationToSeconds(control.staleWhileRevalidate); directives.push(`stale-while-revalidate=${seconds}`); } return directives.length > 0 ? directives.join(", ") : undefined; } public shouldStore(options?: EtagMiddlewareOptionsResolved): boolean { if (!options) return false; if (options.store) return true; return false; } public shouldUseEtag(options?: EtagMiddlewareOptionsResolved): boolean { if (!options) return false; if (options.etag) return true; return false; } // ------------------------------------------------------------------------------------------------------------------- // Protected helpers // ------------------------------------------------------------------------------------------------------------------- protected durationToSeconds(duration: number | DurationLike): number { if (typeof duration === "number") { return duration; } return this.time.duration(duration).asSeconds(); } protected createCacheKey(route: ServerRoute, config?: ServerRequest): string { const params: string[] = []; 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. */ protected async collectStreamForCache( stream: ReadableStream<Uint8Array>, key: string, status: number | undefined, contentType: string | undefined, generateEtag: boolean, ): Promise<string | undefined> { const chunks: Uint8Array[] = []; const reader = stream.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); } // Combine chunks into a single string const decoder = new TextDecoder(); const body = chunks .map((chunk) => decoder.decode(chunk, { stream: true })) .join("") + decoder.decode(); // Flush remaining 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 : undefined; } finally { reader.releaseLock(); } } } // --------------------------------------------------------------------------------------------------------------------- interface RouteCacheEntry { contentType?: string; body: any; status?: number; lastModified: string; hash: string; }