diff --git a/web/server/.env.example b/web/server/.env.example index 6603804..8580c2f 100644 --- a/web/server/.env.example +++ b/web/server/.env.example @@ -7,6 +7,15 @@ GROQ_API_KEY=your_groq_api_key_here # Server port (optional, defaults to 5000) PORT=5000 +# Set to "production" in production deployments. +# Enables static file serving of the built React app (web/client/dist). +# Run "npm run build" in web/client before starting the server. +NODE_ENV=production + +# Allowed CORS origins (comma-separated). Defaults to localhost dev ports. +# Set this to your production domain(s) in production. +# CORS_ORIGIN=https://yourdomain.com + # Python interpreter for the retrieval daemon (optional, defaults to "python"). # Must be "python", "python3", or an absolute path to a Python 3 executable. # Do not set this to an arbitrary binary -- the value is validated on startup. diff --git a/web/server/index.js b/web/server/index.js index f70b908..fb8c0a5 100644 --- a/web/server/index.js +++ b/web/server/index.js @@ -77,9 +77,13 @@ let chunks = []; try { standards = JSON.parse(fs.readFileSync(path.join(DATA_DIR, "standards.json"), "utf-8")); chunks = JSON.parse(fs.readFileSync(path.join(DATA_DIR, "standards_chunks.json"), "utf-8")); + if (!Array.isArray(standards) || !Array.isArray(chunks)) { + throw new Error("standards.json or standards_chunks.json is not a JSON array"); + } console.log(`[init] Loaded ${standards.length} standards, ${chunks.length} chunks`); } catch (e) { console.error("[init] Failed to load data:", e.message); + console.error("[init] API will return empty results. Run the data pipeline first."); } // Pre-build lookups @@ -198,6 +202,12 @@ function bestChunk(standardId, question) { return best; } +// Serve React production build when NODE_ENV=production +const CLIENT_DIST = path.join(__dirname, "../client/dist"); +if (process.env.NODE_ENV === "production" && fs.existsSync(CLIENT_DIST)) { + app.use(express.static(CLIENT_DIST)); +} + // Routes /** @@ -208,8 +218,8 @@ function bestChunk(standardId, question) { app.get("/api/standards", (req, res) => { const q = sanitizeText(req.query.q || "", 200); const category = sanitizeText(req.query.category || "", 100); - const pageNum = Math.max(1, parseInt(req.query.page) || 1); - const limitNum = Math.min(100, Math.max(1, parseInt(req.query.limit) || 20)); + const pageNum = Math.max(1, parseInt(req.query.page, 10) || 1); + const limitNum = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20)); let results = standards; if (category) results = results.filter((s) => s.category === category); @@ -234,7 +244,7 @@ app.get("/api/standards", (req, res) => { * Returns a single standard by its IS identifier; 404 if not found, 400 if the id is malformed. */ app.get("/api/standards/:id", (req, res) => { - const raw = decodeURIComponent(req.params.id); + const raw = req.params.id; if (!isValidStandardId(raw)) { return res.status(400).json({ error: "Invalid standard ID format." }); } @@ -277,7 +287,7 @@ app.get("/api/stats", (req, res) => { */ app.post("/api/recommend", async (req, res) => { const rawQuery = req.body?.query; - const top_n = Math.min(10, Math.max(1, parseInt(req.body?.top_n) || 5)); + const top_n = Math.min(10, Math.max(1, parseInt(req.body?.top_n, 10) || 5)); const rewrite = req.body?.rewrite === true; const query = sanitizeText(rawQuery, 500); @@ -445,6 +455,22 @@ app.post("/api/chat", async (req, res) => { res.json({ answer }); }); +// SPA fallback: serve index.html for all non-API routes in production +if (process.env.NODE_ENV === "production" && fs.existsSync(CLIENT_DIST)) { + app.get("*", (req, res) => { + res.sendFile(path.join(CLIENT_DIST, "index.html")); + }); +} + +process.on("unhandledRejection", (reason) => { + console.error("[ERROR] Unhandled promise rejection:", reason); +}); + +process.on("uncaughtException", (err) => { + console.error("[ERROR] Uncaught exception:", err.message); + process.exit(1); +}); + const server = app.listen(PORT, () => { console.log(`[init] BIS API running on http://localhost:${PORT}`); });