1import Fastify from "fastify";2import cors from "@fastify/cors";3import { FetchaError, isFetchaError, newId } from "@fetcha/core";4import { closeDb, db, sql } from "@fetcha/db";5import { closeAllDispatchers } from "@fetcha/providers";6import { authenticateApiKey, requireScope } from "./auth";7import { assertProductionConfig, config } from "./config";8import { closeKV, getKV } from "./redis";9import { handleFetch } from "./routes/fetch";10import { registerInternalRoutes } from "./routes/internal";11import { meResponse, usageResponse } from "./routes/misc";12import { closeSession, createSession, getSession, listSessions } from "./routes/sessions";13import { getEngine, startHealthLoop } from "./services/engine";1415export async function buildServer() {16 const app = Fastify({17 logger: {18 level: config.logLevel,19 ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),20 redact: ["req.headers.authorization", "req.headers['x-api-key']", "req.headers.cookie"],21 },22 trustProxy: true,23 bodyLimit: 4 * 1024 * 1024,24 genReqId: () => newId("req"),25 disableRequestLogging: config.env === "production",26 });2728 await app.register(cors, {29 origin: true,30 methods: ["GET", "POST", "DELETE", "OPTIONS"],31 allowedHeaders: ["authorization", "content-type", "x-api-key", "idempotency-key"],32 exposedHeaders: ["x-fetcha-request-id", "x-request-id"],33 });3435 app.addHook("onSend", async (req, reply) => {36 if (!reply.getHeader("x-fetcha-request-id")) reply.header("x-fetcha-request-id", req.id);37 reply.header("x-fetcha-version", config.version);38 reply.header("cache-control", "no-store");39 });4041 app.setErrorHandler((err: unknown, req, reply) => {42 if (isFetchaError(err)) {43 const e = err as FetchaError;44 const rid = e.requestId ?? req.id;45 reply.header("x-fetcha-request-id", rid);46 if (e.code === "RATE_LIMITED" && e.details?.retry_after_ms) reply.header("retry-after", String(Math.ceil(Number(e.details.retry_after_ms) / 1000)));47 return reply.status(e.status).send(e.toJSON(rid));48 }49 const fe = err as { statusCode?: number; validation?: unknown; message?: string; code?: string };50 if (fe.statusCode === 400 || fe.validation || fe.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE" || fe.code === "FST_ERR_CTP_EMPTY_JSON_BODY") {51 return reply.status(400).send(new FetchaError("INVALID_REQUEST", fe.code === "FST_ERR_CTP_INVALID_MEDIA_TYPE" ? "Send a JSON body with Content-Type: application/json." : "Malformed JSON body.").toJSON(req.id));52 }53 if (fe.statusCode === 413) return reply.status(413).send(new FetchaError("INVALID_REQUEST", "Request body too large.").toJSON(req.id));54 if (fe.statusCode === 404) return reply.status(404).send(new FetchaError("NOT_FOUND").toJSON(req.id));55 req.log.error({ err }, "unhandled error");56 return reply.status(500).send(new FetchaError("INTERNAL_ERROR").toJSON(req.id));57 });5859 app.setNotFoundHandler((req, reply) => {60 reply.status(404).send(new FetchaError("NOT_FOUND", `No route for ${req.method} ${req.url.split("?")[0]}.`).toJSON(req.id));61 });6263 // ---- Health ---------------------------------------------------------------64 app.get("/health", async () => ({ status: "ok", version: config.version, time: new Date().toISOString() }));65 app.get("/ready", async (_req, reply) => {66 const checks: Record<string, boolean> = {};67 try {68 await db.execute(sql`select 1`);69 checks.database = true;70 } catch {71 checks.database = false;72 }73 checks.cache = await getKV().ping();74 const engine = await getEngine().catch(() => null);75 checks.providers = Boolean(engine && engine.registry.available().length > 0);76 const ok = checks.database && checks.cache && checks.providers;77 return reply.status(ok ? 200 : 503).send({ status: ok ? "ready" : "degraded", checks, available_networks: engine?.registry.availableNetworks() ?? [] });78 });7980 // ---- Public API v1 ----------------------------------------------------------81 const clientIp = (req: { headers: Record<string, unknown>; ip: string }) => ((req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ?? req.ip) || null;8283 app.post("/v1/fetch", async (req, reply) => {84 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);85 requireScope(principal, "fetch:execute");86 const source = /fetcha-sdk/i.test(String(req.headers["user-agent"] ?? "")) ? "sdk" : "api";87 const out = await handleFetch({ principal, body: req.body, source, clientIp: clientIp(req), userAgent: (req.headers["user-agent"] as string | undefined) ?? null });88 reply.header("x-fetcha-request-id", out.requestId);89 return reply.status(out.status).send(out.body);90 });9192 app.post("/v1/sessions", async (req) => {93 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);94 requireScope(principal, "sessions:write");95 return createSession(principal, req.body, req.headers["idempotency-key"] as string | undefined);96 });97 app.get("/v1/sessions", async (req) => {98 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);99 return listSessions(principal);100 });101 app.get<{ Params: { id: string } }>("/v1/sessions/:id", async (req) => {102 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);103 return getSession(principal, req.params.id);104 });105 app.delete<{ Params: { id: string } }>("/v1/sessions/:id", async (req) => {106 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);107 requireScope(principal, "sessions:write");108 return closeSession(principal, req.params.id);109 });110111 app.get("/v1/me", async (req) => {112 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);113 return meResponse(principal);114 });115 app.get("/v1/usage", async (req) => {116 const principal = await authenticateApiKey(req.headers.authorization, req.headers["x-api-key"] as string | undefined);117 requireScope(principal, "usage:read");118 return usageResponse(principal);119 });120121 // Explicitly unavailable surfaces (never fake functionality).122 app.post("/v1/browser", async () => {123 throw new FetchaError("BROWSER_UNAVAILABLE", "Browser actions are not yet available. Follow the changelog at https://www.fetcha.co/changelog.");124 });125 app.post("/v1/extract", async () => {126 throw new FetchaError("INVALID_REQUEST", "Structured extraction is not yet available on this plan. Follow the changelog at https://www.fetcha.co/changelog.");127 });128129 await registerInternalRoutes(app);130 return app;131}132133async function main() {134 assertProductionConfig();135 const app = await buildServer();136 const engine = await getEngine();137 app.log.info({ providers: engine.registry.available().map((p) => p.id), networks: engine.registry.availableNetworks() }, "providers loaded");138 const stopHealth = startHealthLoop(engine, app.log);139140 const shutdown = async (signal: string) => {141 app.log.info(`${signal} received, shutting down`);142 stopHealth();143 await app.close();144 await Promise.all([closeAllDispatchers(), closeKV(), closeDb()]);145 process.exit(0);146 };147 process.on("SIGINT", () => void shutdown("SIGINT"));148 process.on("SIGTERM", () => void shutdown("SIGTERM"));149150 await app.listen({ port: config.port, host: config.host });151 app.log.info(`Fetcha API listening on http://${config.host}:${config.port}`);152}153154const isMain = process.argv[1] && /server\.ts$|server\.js$/.test(process.argv[1]);155if (isMain) {156 main().catch((e) => {157 console.error(e);158 process.exit(1);159 });160}161