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