Files
growqr-backend/src/index.ts
2026-06-04 21:36:58 +05:30

167 lines
5.6 KiB
TypeScript

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { config } from "./config.js";
import { log } from "./log.js";
import { registry } from "./actors/registry.js";
import { actorRoutes } from "./routes/actors.js";
import { opencodeRoutes } from "./routes/opencode.js";
import { gitRoutes } from "./routes/git.js";
import { userRoutes } from "./routes/users.js";
import { agentRoutes } from "./routes/agents.js";
import { workflowRoutes, workflowRunRoutes } from "./routes/workflows.js";
import { chatRoutes } from "./routes/chat.js";
import { serviceRoutes } from "./routes/services.js";
import { conversationRoutes } from "./routes/conversations.js";
import { growRoutes } from "./routes/grow.js";
import { missionRoutes } from "./routes/missions.js";
import { eventRoutes } from "./routes/events.js";
import { homeRoutes } from "./routes/home.js";
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
import { db } from "./db/client.js";
import { hydratePortAllocator, reconcileOnBoot, ensureCentralGiteaReady } from "./docker/manager.js";
import { initCatalog } from "./agents/catalog.js";
async function main() {
// Boot-time DB sanity + reconcile + central Gitea readiness.
await db.execute("select 1");
await hydratePortAllocator();
// Ensure central Gitea is reachable before accepting traffic (changes.md §2A).
try {
await ensureCentralGiteaReady();
} catch (err) {
log.warn({ err }, "central Gitea not ready at boot — will retry on first provision");
}
// Load prompts & agent modules from disk (changes.md §3: prompts/ + agents/).
// After this, buildUnifiedSystemPrompt() returns the full assembled prompt.
await initCatalog();
await reconcileOnBoot();
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
const app = new Hono();
app.use(
"*",
cors({
origin: config.frontendOrigin.split(",").map((s) => s.trim()),
credentials: true,
allowHeaders: ["authorization", "content-type", "x-growqr-user"],
}),
);
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
log.error({ err }, "unhandled error");
return c.json({ error: "internal" }, 500);
});
app.get("/", (c) =>
c.json({ name: "growqr-backend", status: "ok", env: config.nodeEnv }),
);
app.get("/healthz", async (c) => {
try {
await db.execute("select 1");
return c.json({ ok: true });
} catch (err) {
return c.json(
{ ok: false, error: err instanceof Error ? err.message : String(err) },
503,
);
}
});
// HTTP control plane (auth-gated).
app.route("/users", userRoutes());
app.route("/agents", agentRoutes());
app.route("/workflows", workflowRoutes());
app.route("/workflow-runs", workflowRunRoutes());
app.route("/actors", actorRoutes());
app.route("/grow", growRoutes());
app.route("/missions", missionRoutes());
app.route("/events", eventRoutes());
app.route("/home", homeRoutes());
app.route("/conversations", conversationRoutes());
app.route("/opencode", opencodeRoutes());
app.route("/git", gitRoutes());
app.route("/api/chat", chatRoutes());
app.route("/services", serviceRoutes());
if (process.env.RIVET_ENDPOINT) {
// Self-hosted: embedded engine runs at localhost:6420.
// Proxy frontend Rivet traffic to the engine instead of using registry.handler()
// (handler conflicts with startRunner — they're mutually exclusive).
const proxyRivet = async (c: any) => {
const url = new URL(c.req.url);
const target = new URL(config.rivetEndpoint);
url.protocol = target.protocol;
url.hostname = target.hostname;
url.port = target.port;
url.pathname = url.pathname.replace("/api/rivet", "");
// Forward headers, stripping hop-by-hop ones
const fwdHeaders = new Headers();
for (const [k, v] of c.req.raw.headers.entries()) {
if (k.toLowerCase() === "host") continue;
if (k.toLowerCase() === "transfer-encoding") continue;
fwdHeaders.set(k, v);
}
fwdHeaders.set("Host", target.host);
// For POST/PUT/PATCH, clone the body stream (Hono may have consumed it)
const method = c.req.method.toUpperCase();
const bodyMethods = ["POST", "PUT", "PATCH", "DELETE"];
try {
const rawBody = bodyMethods.includes(method)
? await c.req.raw.clone().arrayBuffer()
: undefined;
const res = await fetch(url.toString(), {
method,
headers: fwdHeaders,
body: rawBody && rawBody.byteLength > 0 ? new Uint8Array(rawBody) : undefined,
});
return new Response(res.body, {
status: res.status,
headers: res.headers,
});
} catch (err) {
log.error({ err, url: url.toString() }, "rivet proxy error");
return c.json({ error: "proxy_error" }, 502);
}
};
app.all("/api/rivet", proxyRivet);
app.all("/api/rivet/*", proxyRivet);
registry.startRunner();
} else {
// Serverless: use registry.handler() for incoming actor traffic.
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
}
serve({ fetch: app.fetch, port: config.port }, (info) => {
log.info(
{
port: info.port,
rivet: config.rivetEndpoint,
giteaPublic: config.giteaPublicUrl,
giteaInternal: config.giteaInternalUrl,
env: config.nodeEnv,
},
"growqr-backend listening",
);
});
}
main().catch((err) => {
log.error({ err }, "fatal startup error");
process.exit(1);
});