feat: add Node.js Express server for IAP and backend auth

Replace Python http.server with Express to handle:
- Large IAP JWT headers that caused 500 errors
- API request proxying with GCP identity tokens
- Static React build serving with SPA fallback

Update api.ts to use relative URLs in production for proxy routing.
This commit is contained in:
Danny 2026-01-16 14:33:26 -06:00
parent 5eb6f9ea31
commit 5195901f01
3 changed files with 74 additions and 16 deletions

View File

@ -1,4 +1,4 @@
# ---------- Build stage (Keep this the same) ---------- # Build stage
FROM node:20-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@ -6,18 +6,11 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# ---------- Diagnostic Production stage ---------- # Production stage
FROM python:3.11-slim FROM node:20-alpine
WORKDIR /app
# Set the working directory to where the React files live COPY --from=build /app/dist ./dist
WORKDIR /usr/share/nginx/html COPY server.js .
RUN npm install express
# Copy build output from the build stage
COPY --from=build /app/dist .
# Expose port 80 (Cloud Run expects this)
EXPOSE 80 EXPOSE 80
CMD ["node", "server.js"]
# Run Python's built-in simple HTTP server
# This server is very "dumb" and will ignore/accept large IAP headers
CMD ["python", "-m", "http.server", "80"]

63
server.js Normal file
View File

@ -0,0 +1,63 @@
const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 80;
const BACKEND_URL = process.env.BACKEND_URL;
app.use(express.json());
// Serve static React build
app.use(express.static(path.join(__dirname, 'dist')));
// Proxy endpoint - fetches identity token and forwards to backend
app.post('/api/chat/stream', async (req, res) => {
try {
// Fetch identity token from GCP metadata server
const tokenResponse = await fetch(
`http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${BACKEND_URL}`,
{ headers: { 'Metadata-Flavor': 'Google' } }
);
const identityToken = await tokenResponse.text();
// Forward request to backend with auth
const backendResponse = await fetch(`${BACKEND_URL}/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${identityToken}`,
},
body: JSON.stringify(req.body),
});
// Stream response back to client
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const reader = backendResponse.body.getReader();
const pump = async () => {
const { done, value } = await reader.read();
if (done) {
res.end();
return;
}
res.write(value);
pump();
};
pump();
} catch (error) {
console.error('Proxy error:', error);
res.status(500).json({ error: 'Proxy error' });
}
});
// SPA fallback - all other routes serve index.html
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

View File

@ -1,6 +1,8 @@
import type { ChatMode } from '@/types/chat' import type { ChatMode } from '@/types/chat'
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' // In production, use relative URL to route through the proxy server
// For local development, set VITE_API_URL=http://localhost:8000 in .env
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT || '/chat/stream' const API_ENDPOINT = import.meta.env.VITE_API_ENDPOINT || '/chat/stream'
const USE_MOCK_DATA = false // Set to true to use mock data for testing const USE_MOCK_DATA = false // Set to true to use mock data for testing