mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat: Implement graceful shutdown and body size guard middleware, and refine TypeScript typings across several routes and components.
This commit is contained in:
@@ -6,9 +6,8 @@ const nextConfig = {
|
||||
transpilePackages: ["@omniroute/open-sse"],
|
||||
allowedDevOrigins: ["192.168.*"],
|
||||
typescript: {
|
||||
// Migration Phase: ignore TS errors during build.
|
||||
// Remove after all 984 type errors are resolved.
|
||||
ignoreBuildErrors: true,
|
||||
// All TS errors resolved — strict checking enforced
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
|
||||
@@ -184,8 +184,10 @@ export default function EvalsTab() {
|
||||
|
||||
// Count total cases and unique models across all suites
|
||||
const totalCases = suites.reduce((sum, s) => sum + (s.cases?.length || s.caseCount || 0), 0);
|
||||
const uniqueModels = [
|
||||
...new Set(suites.flatMap((s) => (s.cases || []).map((c) => c.model).filter(Boolean))),
|
||||
const uniqueModels: string[] = [
|
||||
...new Set(
|
||||
suites.flatMap((s: any) => (s.cases || []).map((c: any) => c.model)).filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
@@ -393,8 +395,8 @@ export default function EvalsTab() {
|
||||
const caseCount = suite.cases?.length || suite.caseCount || 0;
|
||||
|
||||
// Count unique models in this suite
|
||||
const suiteModels = [
|
||||
...new Set((suite.cases || []).map((c) => c.model).filter(Boolean)),
|
||||
const suiteModels: string[] = [
|
||||
...new Set<string>((suite.cases || []).map((c: any) => c.model).filter(Boolean)),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -92,15 +92,15 @@ export async function POST(request) {
|
||||
const result = await handleImageGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error");
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: result.status,
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,15 +79,15 @@ export async function POST(request, { params }) {
|
||||
const result = await handleImageGeneration({ body, credentials, log });
|
||||
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
return new Response(JSON.stringify((result as any).data), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload(result.error, "Image generation provider error");
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: result.status,
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
16
src/instrumentation.ts
Normal file
16
src/instrumentation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook
|
||||
*
|
||||
* Called once when the server starts (both dev and production).
|
||||
* Used to initialize graceful shutdown handlers.
|
||||
*
|
||||
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
|
||||
*/
|
||||
|
||||
export async function register() {
|
||||
// Only run on the server (not during build or in Edge runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { initGracefulShutdown } = await import("@/lib/gracefulShutdown");
|
||||
initGracefulShutdown();
|
||||
}
|
||||
}
|
||||
124
src/lib/gracefulShutdown.ts
Normal file
124
src/lib/gracefulShutdown.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Graceful Shutdown — E-2 Critical Fix
|
||||
*
|
||||
* Handles SIGTERM / SIGINT to drain in-flight requests before exit.
|
||||
* Critical for Docker containers and Kubernetes pods where hard kills
|
||||
* can drop active SSE streams.
|
||||
*
|
||||
* Usage:
|
||||
* import { initGracefulShutdown } from "@/lib/gracefulShutdown";
|
||||
* initGracefulShutdown();
|
||||
*
|
||||
* @module lib/gracefulShutdown
|
||||
*/
|
||||
|
||||
/** Whether we are currently shutting down */
|
||||
let isShuttingDown = false;
|
||||
|
||||
/** Number of in-flight requests being tracked */
|
||||
let activeRequests = 0;
|
||||
|
||||
/** Grace period before forced exit (default 30s, configurable) */
|
||||
const SHUTDOWN_TIMEOUT_MS = parseInt(process.env.SHUTDOWN_TIMEOUT_MS || "30000", 10);
|
||||
|
||||
/**
|
||||
* Check if the server is currently shutting down.
|
||||
* Route handlers can use this to reject new requests.
|
||||
*/
|
||||
export function isDraining(): boolean {
|
||||
return isShuttingDown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a new in-flight request. Call `done()` when it completes.
|
||||
* Returns a done callback.
|
||||
*/
|
||||
export function trackRequest(): () => void {
|
||||
activeRequests++;
|
||||
let called = false;
|
||||
return () => {
|
||||
if (!called) {
|
||||
called = true;
|
||||
activeRequests--;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active request count (for monitoring/health endpoints).
|
||||
*/
|
||||
export function getActiveRequestCount(): number {
|
||||
return activeRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all in-flight requests to complete, with timeout.
|
||||
*/
|
||||
async function waitForDrain(): Promise<void> {
|
||||
const start = Date.now();
|
||||
const CHECK_INTERVAL_MS = 250;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (activeRequests <= 0) {
|
||||
console.log("[Shutdown] All in-flight requests drained.");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - start > SHUTDOWN_TIMEOUT_MS) {
|
||||
console.warn(
|
||||
`[Shutdown] Timeout after ${SHUTDOWN_TIMEOUT_MS}ms with ${activeRequests} active requests. Forcing exit.`
|
||||
);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Shutdown] Waiting for ${activeRequests} in-flight request(s)...`);
|
||||
setTimeout(check, CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup: close DB connections, flush logs.
|
||||
*/
|
||||
async function cleanup(): Promise<void> {
|
||||
try {
|
||||
// Close SQLite database — import dynamically to avoid circular deps
|
||||
const { getDbInstance } = await import("@/lib/db/core");
|
||||
const db = getDbInstance();
|
||||
if (db && typeof db.close === "function") {
|
||||
db.close();
|
||||
console.log("[Shutdown] SQLite database closed.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Shutdown] Error during cleanup:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize graceful shutdown handlers.
|
||||
* Should be called once during server startup.
|
||||
*/
|
||||
export function initGracefulShutdown(): void {
|
||||
const shutdown = async (signal: string) => {
|
||||
if (isShuttingDown) return; // Prevent double-shutdown
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log(`\n[Shutdown] Received ${signal}. Draining ${activeRequests} request(s)...`);
|
||||
|
||||
await waitForDrain();
|
||||
await cleanup();
|
||||
|
||||
console.log("[Shutdown] Bye.");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
|
||||
console.log("[Shutdown] Graceful shutdown handlers registered.");
|
||||
}
|
||||
22
src/proxy.ts
22
src/proxy.ts
@@ -3,6 +3,8 @@ import { jwtVerify } from "jose";
|
||||
import { generateRequestId } from "./shared/utils/requestId";
|
||||
import { getSettings } from "./lib/localDb";
|
||||
import { isPublicRoute, verifyAuth, isAuthRequired } from "./shared/utils/apiAuth";
|
||||
import { checkBodySize } from "./shared/middleware/bodySizeGuard";
|
||||
import { isDraining } from "./lib/gracefulShutdown";
|
||||
|
||||
// FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured.
|
||||
if (!process.env.JWT_SECRET) {
|
||||
@@ -19,6 +21,26 @@ export async function proxy(request) {
|
||||
const response = NextResponse.next();
|
||||
response.headers.set("X-Request-Id", requestId);
|
||||
|
||||
// ──────────────── Pre-flight: Reject during shutdown drain ────────────────
|
||||
if (isDraining() && pathname.startsWith("/api/")) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: "SERVICE_UNAVAILABLE",
|
||||
message: "Server is shutting down",
|
||||
correlation_id: requestId,
|
||||
},
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── Pre-flight: Reject oversized bodies ────────────────
|
||||
if (pathname.startsWith("/api/") && request.method !== "GET" && request.method !== "OPTIONS") {
|
||||
const bodySizeRejection = checkBodySize(request);
|
||||
if (bodySizeRejection) return bodySizeRejection;
|
||||
}
|
||||
|
||||
// ──────────────── Protect Management API Routes ────────────────
|
||||
if (pathname.startsWith("/api/") && !pathname.startsWith("/api/v1/")) {
|
||||
// Allow public routes (login, logout, health, etc.)
|
||||
|
||||
65
src/shared/middleware/bodySizeGuard.ts
Normal file
65
src/shared/middleware/bodySizeGuard.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Body Size Guard — E-1 Critical Fix
|
||||
*
|
||||
* Middleware helper that rejects oversized request bodies
|
||||
* before they are parsed, preventing OOM from malicious payloads.
|
||||
*
|
||||
* Usage:
|
||||
* import { checkBodySize, MAX_BODY_BYTES } from "@/shared/middleware/bodySizeGuard";
|
||||
*
|
||||
* const rejection = checkBodySize(request);
|
||||
* if (rejection) return rejection;
|
||||
*
|
||||
* @module shared/middleware/bodySizeGuard
|
||||
*/
|
||||
|
||||
/** Default maximum body size: 10 MB */
|
||||
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** Larger limit for backup/import routes: 100 MB */
|
||||
export const MAX_BODY_BYTES_IMPORT = 100 * 1024 * 1024;
|
||||
|
||||
/** Configured limit — reads from env or falls back to 10 MB */
|
||||
export const MAX_BODY_BYTES = parseInt(
|
||||
process.env.MAX_BODY_SIZE_BYTES || String(DEFAULT_MAX_BODY_BYTES),
|
||||
10
|
||||
);
|
||||
|
||||
/**
|
||||
* Check Content-Length header against the configured limit.
|
||||
* Returns a 413 Response if the body is too large, or null if OK.
|
||||
*/
|
||||
export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES): Response | null {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
|
||||
if (contentLength) {
|
||||
const bytes = parseInt(contentLength, 10);
|
||||
if (!Number.isNaN(bytes) && bytes > limit) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Request body too large. Maximum allowed: ${formatBytes(limit)}`,
|
||||
type: "payload_too_large",
|
||||
code: "PAYLOAD_TOO_LARGE",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 413,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": process.env.CORS_ORIGIN || "*",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Format bytes as human-readable string */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${bytes} bytes`;
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"skipLibCheck": true,
|
||||
@@ -14,21 +10,16 @@
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@omniroute/open-sse": [
|
||||
"./open-sse"
|
||||
],
|
||||
"@omniroute/open-sse/*": [
|
||||
"./open-sse/*"
|
||||
]
|
||||
"@/*": ["./src/*"],
|
||||
"@omniroute/open-sse": ["./open-sse"],
|
||||
"@omniroute/open-sse/*": ["./open-sse/*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -45,9 +36,5 @@
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"open-sse",
|
||||
"antigravity-manager-analysis"
|
||||
]
|
||||
"exclude": ["node_modules", "open-sse", "antigravity-manager-analysis"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user