Files
GoClaw/server/_core/index.ts
bboxwtf 1b6b8bc2cb feat(phase19): background chat store, UTF-8 SSE fix, DB-backed provider push to gateway
- Chat.tsx: rewritten to use global chatStore singleton — SSE connection survives
  page navigation; added StopCircle cancel button; scrolls only when near bottom
- chatStore.ts: new module-level singleton (EventTarget pattern) that holds all
  conversation/console state; TextDecoder with stream:true for correct UTF-8
- handlers.go (ProvidersReload): now accepts decrypted key in request body from
  Node.js so Go gateway can actually use the API key without sharing crypto logic
- providers.ts (activateProvider): sends decrypted key to gateway via
  notifyGatewayReload(); seedDefaultProvider also calls notifyGatewayReload()
- seed.ts: on startup, after seeding, pushes active provider to gateway with
  retry loop (5 retries × 3 s) to wait for gateway readiness
- index.ts (SSE proxy): TextDecoder('utf-8', {stream:true}) already correct;
  confirmed Cyrillic text arrives ungarbled (e.g. 'Привет!' not '??????????')
2026-03-21 04:12:45 +00:00

127 lines
4.0 KiB
TypeScript

import "dotenv/config";
import express from "express";
import { createServer } from "http";
import net from "net";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { registerOAuthRoutes } from "./oauth";
import { appRouter } from "../routers";
import { createContext } from "./context";
import { serveStatic, setupVite } from "./vite";
import { seedDefaults } from "../seed";
const GATEWAY_BASE_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
function isPortAvailable(port: number): Promise<boolean> {
return new Promise(resolve => {
const server = net.createServer();
server.listen(port, () => {
server.close(() => resolve(true));
});
server.on("error", () => resolve(false));
});
}
async function findAvailablePort(startPort: number = 3000): Promise<number> {
for (let port = startPort; port < startPort + 20; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
throw new Error(`No available port found starting from ${startPort}`);
}
async function startServer() {
const app = express();
const server = createServer(app);
// Configure body parser with larger size limit for file uploads
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ limit: "50mb", extended: true }));
// OAuth callback under /api/oauth/callback
registerOAuthRoutes(app);
// ── SSE proxy: POST /api/orchestrator/stream → Go Gateway SSE ──────────────
// This proxies the SSE stream from the Go Gateway to the browser.
// We need to do it at the Express level because tRPC doesn't support SSE yet.
app.post("/api/orchestrator/stream", async (req, res) => {
try {
const gwRes = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req.body),
});
if (!gwRes.ok || !gwRes.body) {
res.status(gwRes.status).json({ error: "Gateway stream error" });
return;
}
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
res.flushHeaders();
// Pipe the response body — use a single TextDecoder with stream:true
// so multi-byte UTF-8 sequences (Cyrillic, CJK, etc.) are never split
const reader = gwRes.body.getReader();
const decoder = new TextDecoder("utf-8");
const pump = async () => {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
// @ts-ignore
if (res.flush) (res as any).flush();
}
} catch {
// Client disconnected
} finally {
res.end();
}
};
// Abort if client disconnects
req.on("close", () => reader.cancel());
await pump();
} catch (err: any) {
if (!res.headersSent) {
res.status(502).json({ error: `Gateway unreachable: ${err.message}` });
}
}
});
// tRPC API
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
})
);
// development mode uses Vite, production mode uses static files
if (process.env.NODE_ENV === "development") {
await setupVite(app, server);
} else {
serveStatic(app);
}
const preferredPort = parseInt(process.env.PORT || "3000");
const port = await findAvailablePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
}
// Run idempotent seed on every startup (no-op if data already exists)
await seedDefaults();
server.listen(port, () => {
console.log(`Server running on http://localhost:${port}/`);
});
}
startServer().catch(console.error);