UNPKG

alepha

Version:

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

196 lines (178 loc) 6.49 kB
import { createMiddleware, type Middleware } from "alepha"; import type { CachePrimitiveOptions } from "alepha/cache"; import type { DurationLike } from "alepha/datetime"; import { ServerEtagProvider } from "../providers/ServerEtagProvider.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 }) => { ... }, * }); * } * ``` */ export const $etag = (options?: EtagMiddlewareOptions): Middleware => { const resolved = resolveEtagOptions(options); return createMiddleware({ name: "$etag", options: resolved as unknown as Record<string, unknown>, handler: ({ alepha, next }) => { const etagProvider = alepha.inject(ServerEtagProvider); return async (...args) => { const request = alepha.get("alepha.http.request") ?? args[0]; // Set etag options on request metadata for hooks to read if (request?.metadata) { request.metadata.etagOptions = resolved; } // If store is enabled, check cache before handler if (etagProvider.shouldStore(resolved)) { if (request) { const cached = await etagProvider.checkCache(request, resolved); // checkCache sets request.metadata.etagHit on cache hit // cached may be undefined for 304 responses (no body) if (cached !== undefined || request.metadata?.etagHit) { return cached; } } } return next(...args); }; }, }); }; // --------------------------------------------------------------------------------------------------------------------- export function resolveEtagOptions( options?: EtagMiddlewareOptions, ): EtagMiddlewareOptionsResolved { if (options === true) { return { store: true, etag: true }; } if (!options) { return { etag: true }; } return { etag: true, ...options }; } // --------------------------------------------------------------------------------------------------------------------- export type EtagMiddlewareOptions = /** * If true, enables both store and etag. */ | true /** * Object configuration for fine-grained control. */ | { /** * If true, enables storing cached responses. (in-memory, Redis, @see alepha/cache for other providers) * If a DurationLike is provided, it will be used as the TTL for the cache. * If CachePrimitiveOptions is provided, it will be used to configure the cache storage. * * @default false */ store?: true | DurationLike | CachePrimitiveOptions; /** * If true, enables ETag support for the cached responses. * * @default true (always true when using $etag) */ etag?: true; /** * - If true, sets Cache-Control to "public, max-age=300" (5 minutes). * - If string, sets Cache-Control to the provided value directly. * - If object, configures Cache-Control directives. */ control?: | true | string | { /** * Indicates that the response may be cached by any cache. */ public?: boolean; /** * Indicates that the response is intended for a single user and must not be stored by a shared cache. */ private?: boolean; /** * Forces caches to submit the request to the origin server for validation before releasing a cached copy. */ noCache?: boolean; /** * Instructs caches not to store the response. */ noStore?: boolean; /** * Maximum amount of time a resource is considered fresh. * Can be specified as a number (seconds) or as a DurationLike object. */ maxAge?: number | DurationLike; /** * Overrides max-age for shared caches (e.g., CDNs). * Can be specified as a number (seconds) or as a DurationLike object. */ sMaxAge?: number | DurationLike; /** * Indicates that once a resource becomes stale, caches must not use it without successful validation. */ mustRevalidate?: boolean; /** * Similar to must-revalidate, but only for shared caches. */ proxyRevalidate?: boolean; /** * Indicates that the response can be stored but must be revalidated before each use. */ immutable?: boolean; /** * Time window (in seconds or DurationLike) during which a stale response may be served * while a fresh one is fetched in the background. * Supported by Cloudflare, modern browsers, and most CDNs. */ staleWhileRevalidate?: number | DurationLike; }; }; export interface EtagMiddlewareOptionsResolved { store?: true | DurationLike | CachePrimitiveOptions; etag?: true; control?: | true | string | { public?: boolean; private?: boolean; noCache?: boolean; noStore?: boolean; maxAge?: number | DurationLike; sMaxAge?: number | DurationLike; mustRevalidate?: boolean; proxyRevalidate?: boolean; immutable?: boolean; staleWhileRevalidate?: number | DurationLike; }; }