107 lines · 3.9 KB · TypeScript
RawDownload
import { Hono } from "hono";import { swaggerUI } from "@hono/swagger-ui";import { cors } from "hono/cors";import type { AppContext } from "../app-context";import { isHttpStatus } from "../core/errors";import { registerEngineRoutes } from "../modules/engines/routes";import { registerSystemRoutes } from "../modules/system/routes";import { registerModelsRoutes } from "../modules/models/routes"; import { registerAllProxyRoutes } from "../modules/proxy/routes";import { registerStudioRoutes } from "../modules/studio/routes";import { registerAudioRoutes } from "../modules/audio/routes";import { registerSpeechRoutes } from "../modules/speech/routes";import { createOpenApiSpec } from "./openapi-spec";import {  createMutatingAuthMiddleware,  createMutatingRateLimitMiddleware,  createReadRateLimitMiddleware,} from "./security-middleware";import {  createControllerRequestObservabilityMiddleware,  TELEMETRY_SKIP_PATHS,} from "./observability-middleware"; export const createApp = (context: AppContext): Hono => {  const app = new Hono();  const allowedCorsOrigins = context.config.cors_origins ?? [];   app.use(    "*",    cors({      origin: (origin) => (allowedCorsOrigins.includes(origin) ? origin : null),      allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],      allowHeaders: ["Authorization", "Content-Type", "X-API-Key"],      exposeHeaders: [        "X-RateLimit-Limit",        "X-RateLimit-Remaining",        "X-RateLimit-Reset",        "Retry-After",      ],      maxAge: 600,    }),  );   app.use("*", async (ctx, next) => {    if (!TELEMETRY_SKIP_PATHS.has(ctx.req.path)) {      context.logger.debug(`${ctx.req.method} ${ctx.req.path}`);    }    await next();  });   app.use("*", createControllerRequestObservabilityMiddleware(context));  app.use("*", createMutatingRateLimitMiddleware(context));  app.use("*", createReadRateLimitMiddleware(context));  app.use("*", createMutatingAuthMiddleware(context));   registerSystemRoutes(app, context);  registerEngineRoutes(app, context);  registerModelsRoutes(app, context);  registerStudioRoutes(app, context);  registerSpeechRoutes(app, context);  registerAudioRoutes(app, context);  registerAllProxyRoutes(app, context);   app.get("/health", (ctx) => ctx.json({ status: "ok" }));   // OpenAPI documentation endpoints  app.get("/api/spec", (ctx) => ctx.json(createOpenApiSpec(context)));   app.get("/api/docs", swaggerUI({ url: "/api/spec" }));   app.notFound((ctx) => ctx.json({ detail: "Not Found" }, { status: 404 }));   app.onError((error, ctx) => {    if (isHttpStatus(error)) {      return ctx.json({ detail: error.detail }, { status: error.status });    }    // Client-initiated disconnects (stream cancel, page close, Droid    // cancelling an in-flight request to start a new turn) are not our    // bug. They must NEVER surface as 500 "Internal Server Error" or log    // as "Unhandled error". The client's socket is already closed so the    // response body will never reach them anyway; emit a terminal 499    // (client closed request) and move on.    const name = (error as { name?: string })?.name ?? "";    const message = String(error);    if (      name === "AbortError" ||      message.includes("AbortError") ||      message.includes("connection was closed") ||      message.includes("ERR_STREAM_PREMATURE_CLOSE") ||      message.includes("Stream was cancelled") ||      message.includes("stream was cancelled") ||      message.includes("The operation was aborted") ||      message.includes("readable stream is cancelled")    ) {      context.logger.debug("client disconnected mid-request", {        method: ctx.req.method,        path: ctx.req.path,      });      return ctx.body(null, { status: 499 });    }    context.logger.error("Unhandled error", { error: message });    return ctx.json({ detail: "Internal Server Error" }, { status: 500 });  });   return app;};