/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ require('dotenv').config(); const express = require('express'); const fs = require('fs'); const axios = require('axios'); const https = require('https'); const path = require('path'); const WebSocket = require('ws'); const { URLSearchParams, URL } = require('url'); const rateLimit = require('express-rate-limit'); const app = express(); const port = process.env.PORT || 3000; const externalApiBaseUrl = 'https://generativelanguage.googleapis.com'; const externalWsBaseUrl = 'wss://generativelanguage.googleapis.com'; // Support either API key env-var variant const apiKey = process.env.GEMINI_API_KEY || process.env.API_KEY; const staticPath = path.join(__dirname,'dist'); const publicPath = path.join(__dirname,'public'); if (!apiKey) { // Only log an error, don't exit. The server will serve apps without proxy functionality console.error("Warning: GEMINI_API_KEY or API_KEY environment variable is not set! Proxy functionality will be disabled."); } else { console.log("API KEY FOUND (proxy will use this)") } // Limit body size to 50mb app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({extended: true, limit: '50mb'})); app.set('trust proxy', 1 /* number of proxies between user and server */) // Rate limiter for the proxy const proxyLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // Set ratelimit window at 15min (in ms) max: 100, // Limit each IP to 100 requests per window message: 'Too many requests from this IP, please try again after 15 minutes', standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // no `X-RateLimit-*` headers handler: (req, res, next, options) => { console.warn(`Rate limit exceeded for IP: ${req.ip}. Path: ${req.path}`); res.status(options.statusCode).send(options.message); } }); // Apply the rate limiter to the /api-proxy route before the main proxy logic app.use('/api-proxy', proxyLimiter); // Proxy route for Gemini API calls (HTTP) app.use('/api-proxy', async (req, res, next) => { console.log(req.ip); // If the request is an upgrade request, it's for WebSockets, so pass to next middleware/handler if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') { return next(); // Pass to the WebSocket upgrade handler } // Handle OPTIONS request for CORS preflight if (req.method === 'OPTIONS') { res.setHeader('Access-Control-Allow-Origin', '*'); // Adjust as needed for security res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Goog-Api-Key'); res.setHeader('Access-Control-Max-Age', '86400'); // Cache preflight response for 1 day return res.sendStatus(200); } if (req.body) { // Only log body if it exists console.log(" Request Body (from frontend):", req.body); } try { // Construct the target URL by taking the part of the path after /api-proxy/ const targetPath = req.url.startsWith('/') ? req.url.substring(1) : req.url; const apiUrl = `${externalApiBaseUrl}/${targetPath}`; console.log(`HTTP Proxy: Forwarding request to ${apiUrl}`); // Prepare headers for the outgoing request const outgoingHeaders = {}; // Copy most headers from the incoming request for (const header in req.headers) { // Exclude host-specific headers and others that might cause issues upstream if (!['host', 'connection', 'content-length', 'transfer-encoding', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions'].includes(header.toLowerCase())) { outgoingHeaders[header] = req.headers[header]; } } // Set the actual API key in the appropriate header outgoingHeaders['X-Goog-Api-Key'] = apiKey; // Set Content-Type from original request if present (for relevant methods) if (req.headers['content-type'] && ['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) { outgoingHeaders['Content-Type'] = req.headers['content-type']; } else if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) { // Default Content-Type to application/json if no content type for post/put/patch outgoingHeaders['Content-Type'] = 'application/json'; } // For GET or DELETE requests, ensure Content-Type is NOT sent, // even if the client erroneously included it. if (['GET', 'DELETE'].includes(req.method.toUpperCase())) { delete outgoingHeaders['Content-Type']; // Case-sensitive common practice delete outgoingHeaders['content-type']; // Just in case } // Ensure 'accept' is reasonable if not set if (!outgoingHeaders['accept']) { outgoingHeaders['accept'] = '*/*'; } const axiosConfig = { method: req.method, url: apiUrl, headers: outgoingHeaders, responseType: 'stream', validateStatus: function (status) { return true; // Accept any status code, we'll pipe it through }, }; if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) { axiosConfig.data = req.body; } // For GET, DELETE, etc., axiosConfig.data will remain undefined, // and axios will not send a request body. const apiResponse = await axios(axiosConfig); // Pass through response headers from Gemini API to the client for (const header in apiResponse.headers) { res.setHeader(header, apiResponse.headers[header]); } res.status(apiResponse.status); apiResponse.data.on('data', (chunk) => { res.write(chunk); }); apiResponse.data.on('end', () => { res.end(); }); apiResponse.data.on('error', (err) => { console.error('Error during streaming data from target API:', err); if (!res.headersSent) { res.status(500).json({ error: 'Proxy error during streaming from target' }); } else { // If headers already sent, we can't send a JSON error, just end the response. res.end(); } }); } catch (error) { console.error('Proxy error before request to target API:', error); if (!res.headersSent) { if (error.response) { const errorData = { status: error.response.status, message: error.response.data?.error?.message || 'Proxy error from upstream API', details: error.response.data?.error?.details || null }; res.status(error.response.status).json(errorData); } else { res.status(500).json({ error: 'Proxy setup error', message: error.message }); } } } }); const webSocketInterceptorScriptTag = ``; // Prepare service worker registration script content const serviceWorkerRegistrationScript = ` `; // Serve index.html or placeholder based on API key and file availability app.get('/', (req, res) => { const placeholderPath = path.join(publicPath, 'placeholder.html'); // Try to serve index.html console.log("LOG: Route '/' accessed. Attempting to serve index.html."); const indexPath = path.join(staticPath, 'index.html'); fs.readFile(indexPath, 'utf8', (err, indexHtmlData) => { if (err) { // index.html not found or unreadable, serve the original placeholder console.log('LOG: index.html not found or unreadable. Falling back to original placeholder.'); return res.sendFile(placeholderPath); } // If API key is not set, serve original HTML without injection if (!apiKey) { console.log("LOG: API key not set. Serving original index.html without script injections."); return res.sendFile(indexPath); } // index.html found and apiKey set, inject scripts console.log("LOG: index.html read successfully. Injecting scripts."); let injectedHtml = indexHtmlData; if (injectedHtml.includes('')) { // Inject WebSocket interceptor first, then service worker script injectedHtml = injectedHtml.replace( '', `${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}` ); console.log("LOG: Scripts injected into ."); } else { console.warn("WARNING: tag not found in index.html. Prepending scripts to the beginning of the file as a fallback."); injectedHtml = `${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}${indexHtmlData}`; } res.send(injectedHtml); }); }); app.get('/service-worker.js', (req, res) => { return res.sendFile(path.join(publicPath, 'service-worker.js')); }); app.use('/public', express.static(publicPath)); app.use(express.static(staticPath)); // Start the HTTP server const server = app.listen(port, () => { console.log(`Server listening on port ${port}`); console.log(`HTTP proxy active on /api-proxy/**`); console.log(`WebSocket proxy active on /api-proxy/**`); }); // Create WebSocket server and attach it to the HTTP server const wss = new WebSocket.Server({ noServer: true }); server.on('upgrade', (request, socket, head) => { const requestUrl = new URL(request.url, `http://${request.headers.host}`); const pathname = requestUrl.pathname; if (pathname.startsWith('/api-proxy/')) { if (!apiKey) { console.error("WebSocket proxy: API key not configured. Closing connection."); socket.destroy(); return; } wss.handleUpgrade(request, socket, head, (clientWs) => { console.log('Client WebSocket connected to proxy for path:', pathname); const targetPathSegment = pathname.substring('/api-proxy'.length); const clientQuery = new URLSearchParams(requestUrl.search); clientQuery.set('key', apiKey); const targetGeminiWsUrl = `${externalWsBaseUrl}${targetPathSegment}?${clientQuery.toString()}`; console.log(`Attempting to connect to target WebSocket: ${targetGeminiWsUrl}`); const geminiWs = new WebSocket(targetGeminiWsUrl, { protocol: request.headers['sec-websocket-protocol'], }); const messageQueue = []; geminiWs.on('open', () => { console.log('Proxy connected to Gemini WebSocket'); // Send any queued messages while (messageQueue.length > 0) { const message = messageQueue.shift(); if (geminiWs.readyState === WebSocket.OPEN) { // console.log('Sending queued message from client -> Gemini'); geminiWs.send(message); } else { // Should not happen if we are in 'open' event, but good for safety console.warn('Gemini WebSocket not open when trying to send queued message. Re-queuing.'); messageQueue.unshift(message); // Add it back to the front break; // Stop processing queue for now } } }); geminiWs.on('message', (message) => { // console.log('Message from Gemini -> client'); if (clientWs.readyState === WebSocket.OPEN) { clientWs.send(message); } }); geminiWs.on('close', (code, reason) => { console.log(`Gemini WebSocket closed: ${code} ${reason.toString()}`); if (clientWs.readyState === WebSocket.OPEN || clientWs.readyState === WebSocket.CONNECTING) { clientWs.close(code, reason.toString()); } }); geminiWs.on('error', (error) => { console.error('Error on Gemini WebSocket connection:', error); if (clientWs.readyState === WebSocket.OPEN || clientWs.readyState === WebSocket.CONNECTING) { clientWs.close(1011, 'Upstream WebSocket error'); } }); clientWs.on('message', (message) => { if (geminiWs.readyState === WebSocket.OPEN) { // console.log('Message from client -> Gemini'); geminiWs.send(message); } else if (geminiWs.readyState === WebSocket.CONNECTING) { // console.log('Queueing message from client -> Gemini (Gemini still connecting)'); messageQueue.push(message); } else { console.warn('Client sent message but Gemini WebSocket is not open or connecting. Message dropped.'); } }); clientWs.on('close', (code, reason) => { console.log(`Client WebSocket closed: ${code} ${reason.toString()}`); if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) { geminiWs.close(code, reason.toString()); } }); clientWs.on('error', (error) => { console.error('Error on client WebSocket connection:', error); if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) { geminiWs.close(1011, 'Client WebSocket error'); } }); }); } else { console.log(`WebSocket upgrade request for non-proxy path: ${pathname}. Closing connection.`); socket.destroy(); } });