SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
4.0 KB · 98 lines typescript
Raw Blame History
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