alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
347 lines (346 loc) • 11.7 kB
JavaScript
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