Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/production-readiness-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

This is the runbook to make the app publishable in one focused pass.

Last reviewed: 2026-07-04. Applies to any feature branch or release candidate.
Last reviewed: 2026-07-10. Applies to any feature branch or release candidate.

- Runtime target: Next.js 16.2.9, Node 24.x, npm 11.x.
- Runtime target: Next.js 16.2.10, Node 24.x, npm 11.x.
- Supabase target: `sjrfecxgysukkwxsowpy` (`Clinical KB Database`).

## Immediate completion targets
Expand Down
10 changes: 9 additions & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const projectRoot = path.dirname(fileURLToPath(import.meta.url));
// request from src/proxy.ts; both derive their runtime flags from the same helper.
const securityHeaders = buildSecurityHeaders(resolveRuntimeFlags());

// Opt-in bundle analysis (npm run build:analyze). The analyzer is a devDependency
// loaded lazily so production runtimes (pruned node_modules) never import it.
async function withOptionalBundleAnalyzer(config: NextConfig): Promise<NextConfig> {
if (process.env.ANALYZE !== "true") return config;
const { default: bundleAnalyzer } = await import("@next/bundle-analyzer");
return bundleAnalyzer({ enabled: true })(config);
}

const nextConfig: NextConfig = {
// Playwright and some local tooling hit the dev server via 127.0.0.1; without
// this, Next blocks HMR/client hydration from that host and phone scroll-hide
Expand Down Expand Up @@ -41,4 +49,4 @@ const nextConfig: NextConfig = {
},
};

export default nextConfig;
export default withOptionalBundleAnalyzer(nextConfig);
186 changes: 186 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"preinstall": "node scripts/check-node-engine.cjs",
"ensure": "node scripts/ensure-local-server.mjs",
"build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack",
"build:analyze": "node scripts/build-analyze.mjs",
"start": "node scripts/dev-free-port.mjs start",
"lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern",
"typecheck": "node ./node_modules/typescript/bin/tsc --noEmit",
Expand Down Expand Up @@ -135,6 +136,7 @@
"uuid": "^11.1.1"
},
"devDependencies": {
"@next/bundle-analyzer": "^16.2.10",
"@tailwindcss/postcss": "^4.3.2",
"@types/node": "^24.13.2",
"@types/pdf-parse": "^1.1.5",
Expand Down
13 changes: 13 additions & 0 deletions scripts/build-analyze.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Cross-platform wrapper for `ANALYZE=true npm run build` (Windows shells cannot
// set inline env vars). Reuses the normal build script so the guard and heap
// settings stay in one place; next.config.ts picks up ANALYZE and wraps the
// config with @next/bundle-analyzer, which writes .next/analyze/*.html.
import { spawnSync } from "node:child_process";

const result = spawnSync("npm", ["run", "build"], {
stdio: "inherit",
shell: true,
env: { ...process.env, ANALYZE: "true" },
});

process.exit(result.status ?? 1);
21 changes: 15 additions & 6 deletions src/app/api/answer/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
sourceGovernanceWarnings,
} from "@/lib/source-governance";
import { parseJsonBody } from "@/lib/validation/body";
import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing";
import { createAdminClient } from "@/lib/supabase/admin";
import { logAnswerDiagnostics } from "@/lib/answer-telemetry";
import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors";
Expand Down Expand Up @@ -69,6 +70,7 @@ function buildDemoAnswerPayload(body: AnswerRequestBody, fallbackReason?: string
}

export async function POST(request: Request) {
const routeStartedAt = Date.now();
let body: AnswerRequestBody | null = null;
try {
const answerBody = await parseJsonBody(request, answerSchema, "Invalid answer request.");
Expand Down Expand Up @@ -154,12 +156,19 @@ export async function POST(request: Request) {

logAnswerDiagnostics({ supabase, query: answerBody.query, ownerId: access.ownerId, answer });

return NextResponse.json({
...answer,
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
});
// Durations only — see server-timing.ts for the trust-boundary constraint.
const serverTiming = buildServerTimingHeader(
answerServerTimingEntries(answer.latencyTimings, Date.now() - routeStartedAt),
);
return NextResponse.json(
{
...answer,
degradedMode: answerDegradedModeSignal(answer),
scope: { ...scope, queryMode: answerBody.queryMode },
sourceGovernanceWarnings: warnings,
},
serverTiming ? { headers: { "Server-Timing": serverTiming } } : undefined,
);
} catch (error) {
if (error instanceof serverAuth.AuthenticationError) {
return serverAuth.unauthorizedResponse(error);
Expand Down
Loading
Loading