Spaces:
Running
Running
File size: 14,686 Bytes
7a99b16 01b57bd 7a99b16 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
/**
* @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 = `<script src="/public/websocket-interceptor.js" defer></script>`;
// Prepare service worker registration script content
const serviceWorkerRegistrationScript = `
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load' , () => {
navigator.serviceWorker.register('./service-worker.js')
.then(registration => {
console.log('Service Worker registered successfully with scope:', registration.scope);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
});
} else {
console.log('Service workers are not supported in this browser.');
}
</script>
`;
// 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('<head>')) {
// Inject WebSocket interceptor first, then service worker script
injectedHtml = injectedHtml.replace(
'<head>',
`<head>${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}`
);
console.log("LOG: Scripts injected into <head>.");
} else {
console.warn("WARNING: <head> 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('utf8'));
}
});
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();
}
});
|