SPB Git

spb/fetcha

Public
7.7 KB · 161 lines typescript
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
Files · 6b215c2d5f54167737c2d57307f22c84bc934434
Menu

Theme