From 485e220c4926ce5e76e650873a02b7581cb4e246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 9 Apr 2026 16:42:34 +0200 Subject: [PATCH] fix(api,web): env startup validation, QueryClient defaults, warn on missing REDIS_URL - Throw at startup in production if REDIS_URL/DATABASE_URL/NEXTAUTH_SECRET missing - Warn in development when REDIS_URL falls back to localhost - QueryClient: add gcTime, disable refetchOnWindowFocus, skip retry on 4xx Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/instrumentation.ts | 7 +++++++ apps/web/src/lib/trpc/provider.tsx | 8 +++++++- packages/api/src/index.ts | 1 + packages/api/src/lib/cache.ts | 6 +++++- packages/api/src/lib/env.ts | 15 +++++++++++++++ packages/api/src/sse/event-bus.ts | 6 +++++- 6 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/lib/env.ts diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index 3ee6217..d3256cc 100644 --- a/apps/web/src/instrumentation.ts +++ b/apps/web/src/instrumentation.ts @@ -1,4 +1,11 @@ export async function register() { + // Validate required env vars at startup — throws immediately in production + // if REDIS_URL, DATABASE_URL, or NEXTAUTH_SECRET are missing. + if (process.env.NEXT_RUNTIME === "nodejs") { + const { assertProductionEnv } = await import("@capakraken/api"); + assertProductionEnv(); + } + // Only load Sentry in production — the worker.js crash in dev mode // (vendor-chunks/lib/worker.js MODULE_NOT_FOUND) makes the dev server unstable if (process.env.NODE_ENV === "production") { diff --git a/apps/web/src/lib/trpc/provider.tsx b/apps/web/src/lib/trpc/provider.tsx index cea1419..168c6bc 100644 --- a/apps/web/src/lib/trpc/provider.tsx +++ b/apps/web/src/lib/trpc/provider.tsx @@ -50,12 +50,18 @@ export function TRPCProvider({ children }: { children: React.ReactNode }) { defaultOptions: { queries: { staleTime: 60_000, // 60 seconds — reduces refetches on navigation + gcTime: 5 * 60_000, // 5 minutes — keep inactive queries in memory refetchOnWindowFocus: false, refetchOnReconnect: false, retry: (failureCount, error) => { // Never retry UNAUTHORIZED — redirect immediately instead if (isUnauthorizedTrpcError(error)) return false; - return failureCount < 1; + // Don't retry on 4xx errors (auth, not found, bad input) + if (error instanceof TRPCClientError) { + const code = error.data?.httpStatus as number | undefined; + if (code !== undefined && code >= 400 && code < 500) return false; + } + return failureCount < 2; }, }, }, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index e345156..1346938 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -13,3 +13,4 @@ export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js"; export { createAuditEntry, computeDiff, generateSummary } from "./lib/audit.js"; export { loggedAiCall } from "./ai-client.js"; +export { assertProductionEnv } from "./lib/env.js"; diff --git a/packages/api/src/lib/cache.ts b/packages/api/src/lib/cache.ts index cea1edb..8a02e11 100644 --- a/packages/api/src/lib/cache.ts +++ b/packages/api/src/lib/cache.ts @@ -1,7 +1,11 @@ import { Redis } from "ioredis"; import { logger } from "./logger.js"; -const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380"; +const REDIS_URL = process.env["REDIS_URL"] ?? ( + process.env["NODE_ENV"] !== "production" + ? (console.warn("[env] REDIS_URL not set, using localhost fallback"), "redis://localhost:6380") + : (() => { throw new Error("REDIS_URL required in production"); })() +); const KEY_PREFIX = "dashboard:"; const DEFAULT_TTL_SECONDS = 60; diff --git a/packages/api/src/lib/env.ts b/packages/api/src/lib/env.ts new file mode 100644 index 0000000..c0e55bd --- /dev/null +++ b/packages/api/src/lib/env.ts @@ -0,0 +1,15 @@ +/** + * Startup environment validation. + * Call assertProductionEnv() early in the server lifecycle to surface missing + * critical configuration before any requests are served. + */ +export function assertProductionEnv(): void { + if (process.env["NODE_ENV"] !== "production") return; + + const required = ["REDIS_URL", "DATABASE_URL", "NEXTAUTH_SECRET"] as const; + const missing = required.filter((k) => !process.env[k]); + + if (missing.length > 0) { + throw new Error(`Missing required env vars: ${missing.join(", ")}`); + } +} diff --git a/packages/api/src/sse/event-bus.ts b/packages/api/src/sse/event-bus.ts index c5efbd6..17962b9 100644 --- a/packages/api/src/sse/event-bus.ts +++ b/packages/api/src/sse/event-bus.ts @@ -180,7 +180,11 @@ export function cancelPendingEvents(): void { } // Redis connection — use env var REDIS_URL or fallback to default dev URL -const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380"; +const REDIS_URL = process.env["REDIS_URL"] ?? ( + process.env["NODE_ENV"] !== "production" + ? (console.warn("[env] REDIS_URL not set, using localhost fallback"), "redis://localhost:6380") + : (() => { throw new Error("REDIS_URL required in production"); })() +); const CHANNEL = "capakraken:sse"; let publisher: Redis | null = null;