feat: Implement graceful shutdown and body size guard middleware, and refine TypeScript typings across several routes and components.

This commit is contained in:
diegosouzapw
2026-02-18 12:47:36 -03:00
parent 4c93f0618c
commit 1a7b7e8799
9 changed files with 247 additions and 32 deletions

View File

@@ -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,

View File

@@ -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 (

View File

@@ -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" },
});
}

View File

@@ -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
View 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
View 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.");
}

View File

@@ -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.)

View 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`;
}

View File

@@ -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"]
}