spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/**2 * HTTP plumbing shared by every route: envelope, ETag / Cache-Control, zod query parsing, error types.3 */4import { createHash } from "node:crypto";5import type { FastifyReply, FastifyRequest } from "fastify";6import type { ZodType } from "zod";7import type { ApiEnvelope, SourceRef } from "@dci/core";89export class HttpError extends Error {10 constructor(readonly statusCode: number, message: string, readonly details?: unknown) {11 super(message);12 this.name = "HttpError";13 }14}1516export function notFound(what = "resource"): HttpError {17 return new HttpError(404, `${what} not found`);18}1920export function badRequest(message: string, details?: unknown): HttpError {21 return new HttpError(400, message, details);22}2324/** Parse `req.query` with a zod schema; a failure becomes a 400 with the zod issues. */25export function parseQuery<T>(schema: ZodType<T>, query: unknown): T {26 const res = schema.safeParse(query ?? {});27 if (!res.success) throw new HttpError(400, "invalid query", res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })));28 return res.data;29}3031export function parseBody<T>(schema: ZodType<T>, body: unknown): T {32 const res = schema.safeParse(body ?? {});33 if (!res.success) throw new HttpError(400, "invalid body", res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })));34 return res.data;35}3637/** Cache lifetimes (seconds) per route family. */38export const TTL = { dashboard: 60, list: 120, detail: 300, map: 300, events: 30, search: 60, sitemap: 600, rankings: 300 } as const;3940export interface SendOptions {41 meta?: ApiEnvelope<unknown>["meta"];42 sources?: SourceRef[];43 /** s-maxage in seconds; 0 = no-store */44 ttl?: number;45 cache?: "HIT" | "MISS" | "BYPASS";46}4748export function envelope<T>(data: T, meta?: ApiEnvelope<T>["meta"], sources?: SourceRef[]): ApiEnvelope<T> {49 const out: ApiEnvelope<T> = { data };50 out.meta = { ...(meta ?? {}), generatedAt: meta?.generatedAt ?? new Date().toISOString() };51 if (sources) out.sources = sources;52 return out;53}5455/** Weak ETag over the body with the volatile `generatedAt` stamp removed, so unchanged data validates across cache refreshes. */56export function etagOf(body: string): string {57 return `W/"${createHash("sha1").update(body.replace(/"generatedAt":"[^"]*",?/g, "")).digest("base64url").slice(0, 27)}"`;58}5960/**61 * Send a JSON payload with ETag (304 on If-None-Match), Cache-Control and X-Cache headers.62 * `payload` is sent as-is (callers pass an envelope or an already-cached envelope).63 */64export function sendJson(req: FastifyRequest, reply: FastifyReply, payload: unknown, opts: SendOptions = {}): FastifyReply {65 const body = JSON.stringify(payload);66 const tag = etagOf(body);67 const ttl = opts.ttl ?? 0;68 reply.header("etag", tag);69 reply.header("vary", "accept-encoding");70 if (ttl > 0) reply.header("cache-control", `public, max-age=${Math.min(ttl, 60)}, s-maxage=${ttl}, stale-while-revalidate=${ttl}`);71 else reply.header("cache-control", "no-store");72 if (opts.cache) reply.header("x-cache", opts.cache);73 const inm = req.headers["if-none-match"];74 if (inm && inm.split(",").map((s) => s.trim()).includes(tag)) return reply.code(304).send();75 reply.type("application/json; charset=utf-8");76 return reply.send(body);77}7879/** Stable cache key from route + sorted query. */80export function cacheKeyFor(routePrefix: string, query: unknown): string {81 const q = (query ?? {}) as Record<string, unknown>;82 const parts = Object.keys(q)83 .filter((k) => q[k] !== undefined && q[k] !== "")84 .sort()85 .map((k) => `${k}=${Array.isArray(q[k]) ? (q[k] as unknown[]).join(",") : String(q[k])}`);86 return `${routePrefix}?${parts.join("&")}`;87}8889export function clampInt(v: number | undefined, lo: number, hi: number, dflt: number): number {90 if (v == null || !Number.isFinite(v)) return dflt;91 return Math.max(lo, Math.min(hi, Math.trunc(v)));92}9394export function csv(v: string | undefined | null): string[] {95 if (!v) return [];96 return v.split(",").map((s) => s.trim()).filter(Boolean);97}98