prithivMLmods commited on
Commit
1faf799
·
verified ·
1 Parent(s): 96ba68b

update app

Browse files
Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build the frontend, and install server dependencies
2
+ FROM node:22 AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy all files from the current directory
7
+ COPY . ./
8
+ RUN echo "API_KEY=PLACEHOLDER" > ./.env
9
+ RUN echo "GEMINI_API_KEY=PLACEHOLDER" >> ./.env
10
+
11
+ # Install server dependencies
12
+ WORKDIR /app/server
13
+ RUN npm install
14
+
15
+ # Install dependencies and build the frontend
16
+ WORKDIR /app
17
+ RUN mkdir dist
18
+ RUN bash -c 'if [ -f package.json ]; then npm install && npm run build; fi'
19
+
20
+
21
+ # Stage 2: Build the final server image
22
+ FROM node:22
23
+
24
+ WORKDIR /app
25
+
26
+ #Copy server files
27
+ COPY --from=builder /app/server .
28
+ # Copy built frontend assets from the builder stage
29
+ COPY --from=builder /app/dist ./dist
30
+
31
+ EXPOSE 3000
32
+
33
+ CMD ["node", "server.js"]
Home.tsx ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ /* tslint:disable */
6
+ // FIX: Import `PersonGeneration` and `SafetyFilterLevel` enums from `@google/genai` to fix type errors.
7
+ import {
8
+ GoogleGenAI,
9
+ PersonGeneration,
10
+ SafetyFilterLevel,
11
+ } from '@google/genai';
12
+ import JSZip from 'jszip';
13
+ import {
14
+ Archive,
15
+ Download,
16
+ ImageIcon,
17
+ LoaderCircle,
18
+ SendHorizontal,
19
+ SlidersHorizontal,
20
+ Trash2,
21
+ X,
22
+ } from 'lucide-react';
23
+ import {useState} from 'react';
24
+
25
+ const ai = new GoogleGenAI({apiKey: process.env.API_KEY});
26
+
27
+ function parseError(error: string): React.ReactNode {
28
+ if (error.includes('429') && error.includes('RESOURCE_EXHAUSTED')) {
29
+ return (
30
+ <>
31
+ You've exceeded your current API quota (Rate Limit). This is a usage
32
+ limit on Google's servers.
33
+ <br />
34
+ <br />
35
+ Please check your plan and billing details, or try again after some
36
+ time. For more information, visit the{' '}
37
+ <a
38
+ href="https://ai.google.dev/gemini-api/docs/rate-limits"
39
+ target="_blank"
40
+ rel="noopener noreferrer"
41
+ className="text-blue-600 underline hover:text-blue-800"
42
+ >
43
+ Gemini API rate limits documentation
44
+ </a>
45
+ .
46
+ </>
47
+ );
48
+ }
49
+ const regex = /"message":\s*"(.*?)"/g;
50
+ const match = regex.exec(error);
51
+ if (match && match[1]) {
52
+ return match[1];
53
+ }
54
+ return error;
55
+ }
56
+
57
+
58
+ export default function Home() {
59
+ const [prompt, setPrompt] = useState('');
60
+ const [generatedImages, setGeneratedImages] = useState<string[]>([]);
61
+ const [isLoading, setIsLoading] = useState(false);
62
+ const [isZipping, setIsZipping] = useState(false);
63
+ const [showErrorModal, setShowErrorModal] = useState(false);
64
+ const [errorMessage, setErrorMessage] = useState<React.ReactNode>('');
65
+ const [numberOfImages, setNumberOfImages] = useState(2);
66
+ const [aspectRatio, setAspectRatio] = useState('1:1');
67
+ const [showSettings, setShowSettings] = useState(false);
68
+
69
+ // New state for advanced settings
70
+ const [model, setModel] = useState('imagen-4.0-fast-generate-001');
71
+ // FIX: Use the PersonGeneration enum for the personGeneration state to fix type errors.
72
+ const [personGeneration, setPersonGeneration] = useState<PersonGeneration>(
73
+ PersonGeneration.ALLOW_ALL,
74
+ );
75
+
76
+ const handleClear = () => {
77
+ setGeneratedImages([]);
78
+ setPrompt('');
79
+ setNumberOfImages(2);
80
+ setAspectRatio('1:1');
81
+ setModel('imagen-4.0-fast-generate-001');
82
+ // FIX: Use enum members to set state, resolving type errors.
83
+ setPersonGeneration(PersonGeneration.ALLOW_ALL);
84
+ };
85
+
86
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
87
+ e.preventDefault();
88
+ if (!prompt) {
89
+ setErrorMessage('Please enter a prompt to generate an image.');
90
+ setShowErrorModal(true);
91
+ return;
92
+ }
93
+ setIsLoading(true);
94
+ setGeneratedImages([]); // Clear previous results
95
+
96
+ try {
97
+ const response = await ai.models.generateImages({
98
+ model,
99
+ prompt,
100
+ config: {
101
+ numberOfImages: Number(numberOfImages),
102
+ aspectRatio,
103
+ personGeneration,
104
+ },
105
+ });
106
+
107
+ const imageUrls = response.generatedImages.map(
108
+ (img) => `data:image/png;base64,${img.image.imageBytes}`,
109
+ );
110
+ setGeneratedImages(imageUrls);
111
+ } catch (error) {
112
+ console.error('Error generating images:', error);
113
+ const rawMessage = (error as Error).message || 'An unexpected error occurred.';
114
+ setErrorMessage(parseError(rawMessage));
115
+ setShowErrorModal(true);
116
+ } finally {
117
+ setIsLoading(false);
118
+ }
119
+ };
120
+
121
+ const handleDownload = (src: string, index: number) => {
122
+ const link = document.createElement('a');
123
+ link.href = src;
124
+ link.download = `imagen4-studio-${index + 1}.png`;
125
+ document.body.appendChild(link);
126
+ link.click();
127
+ document.body.removeChild(link);
128
+ };
129
+
130
+ const handleDownloadAll = async () => {
131
+ if (generatedImages.length === 0) return;
132
+
133
+ setIsZipping(true);
134
+ try {
135
+ const zip = new JSZip();
136
+ for (let i = 0; i < generatedImages.length; i++) {
137
+ const src = generatedImages[i];
138
+ const base64Data = src.split(',')[1];
139
+ zip.file(`imagen4-studio-${i + 1}.png`, base64Data, {base64: true});
140
+ }
141
+
142
+ const content = await zip.generateAsync({type: 'blob'});
143
+
144
+ const link = document.createElement('a');
145
+ link.href = URL.createObjectURL(content);
146
+ link.download = 'imagen4-studio-images.zip';
147
+ document.body.appendChild(link);
148
+ link.click();
149
+ document.body.removeChild(link);
150
+ URL.revokeObjectURL(link.href);
151
+ } catch (error) {
152
+ console.error('Error creating zip file:', error);
153
+ setErrorMessage('Failed to create the zip file.');
154
+ setShowErrorModal(true);
155
+ } finally {
156
+ setIsZipping(false);
157
+ }
158
+ };
159
+
160
+ const closeErrorModal = () => {
161
+ setShowErrorModal(false);
162
+ };
163
+
164
+ const models = [
165
+ {id: 'imagen-4.0-fast-generate-001', name: 'Imagen 4 Fast'},
166
+ {id: 'imagen-4.0-generate-001', name: 'Imagen 4'},
167
+ {id: 'imagen-4.0-ultra-generate-001', name: 'Imagen 4 Ultra'},
168
+ ];
169
+
170
+ // FIX: Use enum members for option IDs to ensure type safety.
171
+ const personGenerationOptions = [
172
+ {id: PersonGeneration.ALLOW_ALL, name: 'Allow All'},
173
+ {id: PersonGeneration.ALLOW_ADULT, name: 'Allow Adults'},
174
+ {id: PersonGeneration.DONT_ALLOW, name: "Don't Allow"},
175
+ ];
176
+
177
+ const aspectRatios = ['1:1', '16:9', '4:3', '3:4', '9:16'];
178
+
179
+ const SettingButton = ({onClick, current, value, children}) => (
180
+ <button
181
+ type="button"
182
+ onClick={() => onClick(value)}
183
+ className={`px-3 py-1.5 text-sm font-semibold rounded-md transition-colors ${
184
+ current === value
185
+ ? 'bg-black text-white'
186
+ : 'bg-gray-200 hover:bg-gray-300'
187
+ }`}>
188
+ {children}
189
+ </button>
190
+ );
191
+
192
+ return (
193
+ <>
194
+ <div className="min-h-screen text-gray-900 flex flex-col justify-start items-center">
195
+ <main className="container mx-auto px-3 sm:px-6 py-5 sm:py-10 pb-32 max-w-5xl w-full">
196
+ <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center mb-4 sm:mb-8 gap-2">
197
+ <div>
198
+ <h1 className="text-2xl sm:text-3xl font-bold mb-0 leading-tight">
199
+ IMAGEN4 AIO
200
+ </h1>
201
+ <p className="text-sm sm:text-base text-gray-500 mt-1">
202
+ Powered by the{' '}
203
+ <a
204
+ className="underline"
205
+ href="https://aistudio.google.com/app/apikey"
206
+ target="_blank"
207
+ rel="noopener noreferrer">
208
+ Google Gemini API
209
+ </a>
210
+ </p>
211
+ </div>
212
+ <div className="flex items-center gap-2 self-start sm:self-auto">
213
+ <button
214
+ type="button"
215
+ onClick={() => setShowSettings(!showSettings)}
216
+ className={`w-12 h-12 rounded-full flex items-center justify-center shadow-sm transition-all hover:scale-110 ${
217
+ showSettings ? 'bg-gray-200' : 'bg-white hover:bg-gray-50'
218
+ }`}
219
+ aria-label="Toggle Settings">
220
+ <SlidersHorizontal className="w-6 h-6 text-gray-700" />
221
+ </button>
222
+ <button
223
+ type="button"
224
+ onClick={handleDownloadAll}
225
+ disabled={isLoading || isZipping || generatedImages.length === 0}
226
+ className="w-12 h-12 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110 disabled:scale-100 disabled:cursor-not-allowed disabled:bg-gray-200"
227
+ aria-label="Download all images as a zip">
228
+ {isZipping ? (
229
+ <LoaderCircle className="w-6 h-6 animate-spin text-gray-700" />
230
+ ) : (
231
+ <Archive className="w-6 h-6 text-gray-700" />
232
+ )}
233
+ </button>
234
+ <button
235
+ type="button"
236
+ onClick={handleClear}
237
+ className="w-12 h-12 rounded-full flex items-center justify-center bg-white shadow-sm transition-all hover:bg-gray-50 hover:scale-110"
238
+ aria-label="Clear Results">
239
+ <Trash2 className="w-6 h-6 text-gray-700" />
240
+ </button>
241
+ </div>
242
+ </div>
243
+
244
+ <div
245
+ className={`w-full mb-6 h-[60vh] bg-gray-200/50 rounded-lg flex justify-center p-4 border-2 border-dashed border-gray-300 overflow-y-auto ${
246
+ generatedImages.length > 0 ? 'items-start' : 'items-center'
247
+ }`}>
248
+ {isLoading ? (
249
+ <div className="text-center text-gray-600 self-center">
250
+ <LoaderCircle className="w-12 h-12 animate-spin mx-auto" />
251
+ <p className="mt-4 font-semibold">Generating images...</p>
252
+ <p className="text-sm text-gray-500">This may take a moment</p>
253
+ </div>
254
+ ) : generatedImages.length > 0 ? (
255
+ <div
256
+ className={`grid gap-4 w-full ${
257
+ generatedImages.length > 1
258
+ ? 'grid-cols-1 sm:grid-cols-2'
259
+ : 'grid-cols-1'
260
+ }`}>
261
+ {generatedImages.map((src, index) => (
262
+ <div
263
+ key={index}
264
+ className="relative group flex items-center justify-center bg-black/5 rounded-md overflow-hidden">
265
+ <img
266
+ src={src}
267
+ alt={`Generated image ${index + 1}`}
268
+ className="max-w-full max-h-full object-contain rounded-md"
269
+ />
270
+ <button
271
+ onClick={() => handleDownload(src, index)}
272
+ className="absolute top-2 right-2 p-2 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-all duration-300 hover:scale-110"
273
+ aria-label="Download image">
274
+ <Download className="w-5 h-5" />
275
+ </button>
276
+ </div>
277
+ ))}
278
+ </div>
279
+ ) : (
280
+ <div className="text-center text-gray-500">
281
+ <ImageIcon className="w-12 h-12 mx-auto" />
282
+ <h3 className="font-semibold text-lg mt-4">
283
+ Your generated images will appear here
284
+ </h3>
285
+ <p>Enter a prompt below to get started</p>
286
+ </div>
287
+ )}
288
+ </div>
289
+
290
+ {/* Input form */}
291
+ <form onSubmit={handleSubmit} className="w-full">
292
+ <div className="relative">
293
+ <input
294
+ type="text"
295
+ value={prompt}
296
+ onChange={(e) => setPrompt(e.target.value)}
297
+ placeholder="Describe the image you want to create..."
298
+ className="w-full p-3 sm:p-4 pr-12 sm:pr-14 text-sm sm:text-base border-2 border-black bg-white text-gray-800 shadow-sm focus:ring-2 focus:ring-gray-200 focus:outline-none transition-all h-14"
299
+ required
300
+ />
301
+ <button
302
+ type="submit"
303
+ disabled={isLoading}
304
+ className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 p-1.5 sm:p-2 rounded-none bg-black text-white hover:cursor-pointer hover:bg-gray-800 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
305
+ aria-label="Submit">
306
+ {isLoading ? (
307
+ <LoaderCircle
308
+ className="w-5 sm:w-6 h-5 sm:h-6 animate-spin"
309
+ aria-label="Loading"
310
+ />
311
+ ) : (
312
+ <SendHorizontal className="w-5 sm:w-6 h-5 sm:h-6" />
313
+ )}
314
+ </button>
315
+ </div>
316
+ </form>
317
+ </main>
318
+
319
+ {/* Settings Modal */}
320
+ {showSettings && (
321
+ <div
322
+ className="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4"
323
+ onClick={() => setShowSettings(false)}>
324
+ <div
325
+ className="bg-white rounded-lg shadow-2xl max-w-md w-full"
326
+ onClick={(e) => e.stopPropagation()}>
327
+ <div className="flex justify-between items-center p-4 border-b">
328
+ <h2 className="text-xl font-bold">Settings</h2>
329
+ <button
330
+ type="button"
331
+ onClick={() => setShowSettings(false)}
332
+ className="p-1 rounded-full hover:bg-gray-100"
333
+ aria-label="Close settings">
334
+ <X className="w-6 h-6" />
335
+ </button>
336
+ </div>
337
+ <div className="p-6 space-y-6 overflow-y-auto max-h-[70vh]">
338
+ <div>
339
+ <span className="block text-sm font-medium text-gray-700 mb-2">
340
+ Model
341
+ </span>
342
+ <div className="flex flex-wrap gap-2">
343
+ {models.map((m) => (
344
+ <SettingButton
345
+ key={m.id}
346
+ onClick={setModel}
347
+ current={model}
348
+ value={m.id}>
349
+ {m.name}
350
+ </SettingButton>
351
+ ))}
352
+ </div>
353
+ </div>
354
+
355
+ <div>
356
+ <span className="block text-sm font-medium text-gray-700 mb-2">
357
+ Person Generation
358
+ </span>
359
+ <div className="flex flex-wrap gap-2">
360
+ {personGenerationOptions.map((opt) => (
361
+ <SettingButton
362
+ key={opt.id}
363
+ onClick={setPersonGeneration}
364
+ current={personGeneration}
365
+ value={opt.id}>
366
+ {opt.name}
367
+ </SettingButton>
368
+ ))}
369
+ </div>
370
+ </div>
371
+
372
+ <div>
373
+ <label
374
+ htmlFor="num-images"
375
+ className="block text-sm font-medium text-gray-700 mb-2">
376
+ Number of Images
377
+ </label>
378
+ <input
379
+ type="number"
380
+ id="num-images"
381
+ min="1"
382
+ max="4"
383
+ value={numberOfImages}
384
+ onChange={(e) => {
385
+ const val = Math.max(
386
+ 1,
387
+ Math.min(4, Number(e.target.value)),
388
+ );
389
+ setNumberOfImages(val);
390
+ }}
391
+ className="w-24 p-2 border border-gray-300 rounded-md shadow-sm focus:ring-gray-500 focus:border-gray-500"
392
+ />
393
+ </div>
394
+
395
+ <div>
396
+ <span className="block text-sm font-medium text-gray-700 mb-2">
397
+ Aspect Ratio
398
+ </span>
399
+ <div className="flex flex-wrap gap-2">
400
+ {aspectRatios.map((ratio) => (
401
+ <SettingButton
402
+ key={ratio}
403
+ onClick={setAspectRatio}
404
+ current={aspectRatio}
405
+ value={ratio}>
406
+ {ratio}
407
+ </SettingButton>
408
+ ))}
409
+ </div>
410
+ </div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ )}
415
+
416
+ {/* Error Modal */}
417
+ {showErrorModal && (
418
+ <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
419
+ <div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
420
+ <div className="flex justify-between items-start mb-4">
421
+ <h3 className="text-xl font-bold text-gray-700">
422
+ Generation Failed
423
+ </h3>
424
+ <button
425
+ onClick={closeErrorModal}
426
+ className="text-gray-400 hover:text-gray-500">
427
+ <X className="w-5 h-5" />
428
+ </button>
429
+ </div>
430
+ <div className="font-medium text-gray-600">
431
+ {errorMessage}
432
+ </div>
433
+ </div>
434
+ </div>
435
+ )}
436
+ </div>
437
+ </>
438
+ );
439
+ }
index.css ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;700&display=swap');
3
+
4
+ :root {
5
+ --background: #f3f4f6; /* Tailwind gray-100 */
6
+ --foreground: #171717;
7
+ }
8
+
9
+
10
+
11
+ body {
12
+ color: var(--foreground);
13
+ font-family: 'Outfit', sans-serif;
14
+ background-color: var(--background);
15
+ }
index.html ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>IMAGEN4 AIO</title>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "@google/genai": "https://esm.sh/@google/genai@^0.7.0",
11
+ "react": "https://esm.sh/react@^19.0.0",
12
+ "react/": "https://esm.sh/react@^19.0.0/",
13
+ "react-dom/": "https://esm.sh/react-dom@^19.0.0/",
14
+ "lucide-react": "https://esm.sh/lucide-react@^0.487.0",
15
+ "@tailwindcss/browser": "https://esm.sh/@tailwindcss/browser@^4.1.2",
16
+ "jszip": "https://esm.sh/jszip@^3.10.1"
17
+ }
18
+ }
19
+ </script>
20
+ <link rel="stylesheet" href="/index.css">
21
+ </head>
22
+ <body>
23
+ <div id="root"></div>
24
+ <script type="module" src="/index.tsx"></script>
25
+ </body>
26
+ </html>
index.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+ import '@tailwindcss/browser';
6
+ import './index.css';
7
+
8
+ import ReactDOM from 'react-dom/client';
9
+ import Home from './Home';
10
+
11
+ const root = ReactDOM.createRoot(document.getElementById('root'));
12
+ root.render(<Home />);
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "imagen4-aio",
3
+ "description": "An All-in-One image generation studio powered by Imagen 4.",
4
+ "requestFramePermissions": []
5
+ }
package.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "imagen4-aio",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@google/genai": "^0.7.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0",
15
+ "lucide-react": "^0.487.0",
16
+ "@tailwindcss/browser": "^4.1.2",
17
+ "jszip": "^3.10.1"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^22.14.0",
21
+ "@vitejs/plugin-react": "^5.0.0",
22
+ "typescript": "~5.8.2",
23
+ "vite": "^6.2.0"
24
+ }
25
+ }
server/package.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "appletserver",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "scripts": {
6
+ "start": "node server.js",
7
+ "dev": "nodemon server.js"
8
+ },
9
+ "dependencies": {
10
+ "axios": "^1.6.7",
11
+ "dotenv": "^16.4.5",
12
+ "express": "^4.18.2",
13
+ "express-rate-limit": "^7.5.0",
14
+ "ws": "^8.17.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.14.0",
18
+ "nodemon": "^3.1.0"
19
+ }
20
+ }
server/public/service-worker.js ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ // service-worker.js
7
+
8
+ // Define the target URL that we want to intercept and proxy.
9
+ const TARGET_URL_PREFIX = 'https://generativelanguage.googleapis.com';
10
+
11
+ // Installation event:
12
+ self.addEventListener('install', (event) => {
13
+ try {
14
+ console.log('Service Worker: Installing...');
15
+ event.waitUntil(self.skipWaiting());
16
+ } catch (error) {
17
+ console.error('Service Worker: Error during install event:', error);
18
+ // If skipWaiting fails, the new SW might get stuck in a waiting state.
19
+ }
20
+ });
21
+
22
+ // Activation event:
23
+ self.addEventListener('activate', (event) => {
24
+ try {
25
+ console.log('Service Worker: Activating...');
26
+ event.waitUntil(self.clients.claim());
27
+ } catch (error) {
28
+ console.error('Service Worker: Error during activate event:', error);
29
+ // If clients.claim() fails, the SW might not control existing pages until next nav.
30
+ }
31
+ });
32
+
33
+ // Fetch event:
34
+ self.addEventListener('fetch', (event) => {
35
+ try {
36
+ const requestUrl = event.request.url;
37
+
38
+ if (requestUrl.startsWith(TARGET_URL_PREFIX)) {
39
+ console.log(`Service Worker: Intercepting request to ${requestUrl}`);
40
+
41
+ const remainingPathAndQuery = requestUrl.substring(TARGET_URL_PREFIX.length);
42
+ const proxyUrl = `${self.location.origin}/api-proxy${remainingPathAndQuery}`;
43
+
44
+ console.log(`Service Worker: Proxying to ${proxyUrl}`);
45
+
46
+ // Construct headers for the request to the proxy
47
+ const newHeaders = new Headers();
48
+ // Copy essential headers from the original request
49
+ // For OPTIONS (preflight) requests, Access-Control-Request-* are critical.
50
+ // For actual requests (POST, GET), Content-Type, Accept etc.
51
+ const headersToCopy = [
52
+ 'Content-Type',
53
+ 'Accept',
54
+ 'Access-Control-Request-Method',
55
+ 'Access-Control-Request-Headers',
56
+ ];
57
+
58
+ for (const headerName of headersToCopy) {
59
+ if (event.request.headers.has(headerName)) {
60
+ newHeaders.set(headerName, event.request.headers.get(headerName));
61
+ }
62
+ }
63
+
64
+ if (event.request.method === 'POST') {
65
+
66
+ // Ensure Content-Type is set for POST requests to the proxy, defaulting to application/json
67
+ if (!newHeaders.has('Content-Type')) {
68
+ console.warn("Service Worker: POST request to proxy was missing Content-Type in newHeaders. Defaulting to application/json.");
69
+ newHeaders.set('Content-Type', 'application/json');
70
+ } else {
71
+ console.log(`Service Worker: POST request to proxy has Content-Type: ${newHeaders.get('Content-Type')}`);
72
+ }
73
+ }
74
+
75
+ const requestOptions = {
76
+ method: event.request.method,
77
+ headers: newHeaders, // Use simplified headers
78
+ body: event.request.body, // Still use the original body stream
79
+ mode: event.request.mode,
80
+ credentials: event.request.credentials,
81
+ cache: event.request.cache,
82
+ redirect: event.request.redirect,
83
+ referrer: event.request.referrer,
84
+ integrity: event.request.integrity,
85
+ };
86
+
87
+ // Only set duplex if there's a body and it's a relevant method
88
+ if (event.request.method !== 'GET' && event.request.method !== 'HEAD' && event.request.body ) {
89
+ requestOptions.duplex = 'half';
90
+ }
91
+
92
+ const promise = fetch(new Request(proxyUrl, requestOptions))
93
+ .then((response) => {
94
+ console.log(`Service Worker: Successfully proxied request to ${proxyUrl}, Status: ${response.status}`);
95
+ return response;
96
+ })
97
+ .catch((error) => {
98
+ // Log more error details
99
+ console.error(`Service Worker: Error proxying request to ${proxyUrl}. Message: ${error.message}, Name: ${error.name}, Stack: ${error.stack}`);
100
+ return new Response(
101
+ JSON.stringify({ error: 'Proxying failed', details: error.message, name: error.name, proxiedUrl: proxyUrl }),
102
+ {
103
+ status: 502, // Bad Gateway is appropriate for proxy errors
104
+ headers: { 'Content-Type': 'application/json' }
105
+ }
106
+ );
107
+ });
108
+
109
+ event.respondWith(promise);
110
+
111
+ } else {
112
+ // If the request URL doesn't match our target, let it proceed as normal.
113
+ event.respondWith(fetch(event.request));
114
+ }
115
+ } catch (error) {
116
+ // Log more error details for unhandled errors too
117
+ console.error('Service Worker: Unhandled error in fetch event handler. Message:', error.message, 'Name:', error.name, 'Stack:', error.stack);
118
+ event.respondWith(
119
+ new Response(
120
+ JSON.stringify({ error: 'Service worker fetch handler failed', details: error.message, name: error.name }),
121
+ {
122
+ status: 500,
123
+ headers: { 'Content-Type': 'application/json' }
124
+ }
125
+ )
126
+ );
127
+ }
128
+ });
server/public/websocket-interceptor.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function() {
2
+ const TARGET_WS_HOST = 'generativelanguage.googleapis.com'; // Host to intercept
3
+ const originalWebSocket = window.WebSocket;
4
+
5
+ if (!originalWebSocket) {
6
+ console.error('[WebSocketInterceptor] Original window.WebSocket not found. Cannot apply interceptor.');
7
+ return;
8
+ }
9
+
10
+ const handler = {
11
+ construct(target, args) {
12
+ let [url, protocols] = args;
13
+ //stringify url's if necessary for parsing
14
+ let newUrlString = typeof url === 'string' ? url : (url && typeof url.toString === 'function' ? url.toString() : null);
15
+ //get ready to check for host to proxy
16
+ let isTarget = false;
17
+
18
+ if (newUrlString) {
19
+ try {
20
+ // For full URLs, parse string and check the host
21
+ if (newUrlString.startsWith('ws://') || newUrlString.startsWith('wss://')) {
22
+ //URL object again
23
+ const parsedUrl = new URL(newUrlString);
24
+ if (parsedUrl.host === TARGET_WS_HOST) {
25
+ isTarget = true;
26
+ //use wss if https, else ws
27
+ const proxyScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
28
+ const proxyHost = window.location.host;
29
+ newUrlString = `${proxyScheme}://${proxyHost}/api-proxy${parsedUrl.pathname}${parsedUrl.search}`;
30
+ }
31
+ }
32
+ } catch (e) {
33
+ console.warn('[WebSocketInterceptor-Proxy] Error parsing WebSocket URL, using original:', url, e);
34
+ }
35
+ } else {
36
+ console.warn('[WebSocketInterceptor-Proxy] WebSocket URL is not a string or stringifiable. Using original.');
37
+ }
38
+
39
+ if (isTarget) {
40
+ console.log('[WebSocketInterceptor-Proxy] Original WebSocket URL:', url);
41
+ console.log('[WebSocketInterceptor-Proxy] Redirecting to proxy URL:', newUrlString);
42
+ }
43
+
44
+ // Call the original constructor with potentially modified arguments
45
+ // Reflect.construct ensures 'new target(...)' behavior and correct prototype chain
46
+ if (protocols) {
47
+ return Reflect.construct(target, [newUrlString, protocols]);
48
+ } else {
49
+ return Reflect.construct(target, [newUrlString]);
50
+ }
51
+ },
52
+ get(target, prop, receiver) {
53
+ // Forward static property access (e.g., WebSocket.OPEN, WebSocket.CONNECTING)
54
+ // and prototype access to the original WebSocket constructor/prototype
55
+ if (prop === 'prototype') {
56
+ return target.prototype;
57
+ }
58
+ return Reflect.get(target, prop, receiver);
59
+ }
60
+ };
61
+
62
+ window.WebSocket = new Proxy(originalWebSocket, handler);
63
+
64
+ console.log('[WebSocketInterceptor-Proxy] Global WebSocket constructor has been wrapped using Proxy.');
65
+ })();
server/server.js ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ require('dotenv').config();
8
+ const express = require('express');
9
+ const fs = require('fs');
10
+ const axios = require('axios');
11
+ const https = require('https');
12
+ const path = require('path');
13
+ const WebSocket = require('ws');
14
+ const { URLSearchParams, URL } = require('url');
15
+ const rateLimit = require('express-rate-limit');
16
+
17
+ const app = express();
18
+ const port = process.env.PORT || 3000;
19
+ const externalApiBaseUrl = 'https://generativelanguage.googleapis.com';
20
+ const externalWsBaseUrl = 'wss://generativelanguage.googleapis.com';
21
+ // Support either API key env-var variant
22
+ const apiKey = process.env.GEMINI_API_KEY || process.env.API_KEY;
23
+
24
+ const staticPath = path.join(__dirname,'dist');
25
+ const publicPath = path.join(__dirname,'public');
26
+
27
+
28
+ if (!apiKey) {
29
+ // Only log an error, don't exit. The server will serve apps without proxy functionality
30
+ console.error("Warning: GEMINI_API_KEY or API_KEY environment variable is not set! Proxy functionality will be disabled.");
31
+ }
32
+ else {
33
+ console.log("API KEY FOUND (proxy will use this)")
34
+ }
35
+
36
+ // Limit body size to 50mb
37
+ app.use(express.json({ limit: '50mb' }));
38
+ app.use(express.urlencoded({extended: true, limit: '50mb'}));
39
+ app.set('trust proxy', 1 /* number of proxies between user and server */)
40
+
41
+ // Rate limiter for the proxy
42
+ const proxyLimiter = rateLimit({
43
+ windowMs: 15 * 60 * 1000, // Set ratelimit window at 15min (in ms)
44
+ max: 100, // Limit each IP to 100 requests per window
45
+ message: 'Too many requests from this IP, please try again after 15 minutes',
46
+ standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
47
+ legacyHeaders: false, // no `X-RateLimit-*` headers
48
+ handler: (req, res, next, options) => {
49
+ console.warn(`Rate limit exceeded for IP: ${req.ip}. Path: ${req.path}`);
50
+ res.status(options.statusCode).send(options.message);
51
+ }
52
+ });
53
+
54
+ // Apply the rate limiter to the /api-proxy route before the main proxy logic
55
+ app.use('/api-proxy', proxyLimiter);
56
+
57
+ // Proxy route for Gemini API calls (HTTP)
58
+ app.use('/api-proxy', async (req, res, next) => {
59
+ console.log(req.ip);
60
+ // If the request is an upgrade request, it's for WebSockets, so pass to next middleware/handler
61
+ if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') {
62
+ return next(); // Pass to the WebSocket upgrade handler
63
+ }
64
+
65
+ // Handle OPTIONS request for CORS preflight
66
+ if (req.method === 'OPTIONS') {
67
+ res.setHeader('Access-Control-Allow-Origin', '*'); // Adjust as needed for security
68
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
69
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Goog-Api-Key');
70
+ res.setHeader('Access-Control-Max-Age', '86400'); // Cache preflight response for 1 day
71
+ return res.sendStatus(200);
72
+ }
73
+
74
+ if (req.body) { // Only log body if it exists
75
+ console.log(" Request Body (from frontend):", req.body);
76
+ }
77
+ try {
78
+ // Construct the target URL by taking the part of the path after /api-proxy/
79
+ const targetPath = req.url.startsWith('/') ? req.url.substring(1) : req.url;
80
+ const apiUrl = `${externalApiBaseUrl}/${targetPath}`;
81
+ console.log(`HTTP Proxy: Forwarding request to ${apiUrl}`);
82
+
83
+ // Prepare headers for the outgoing request
84
+ const outgoingHeaders = {};
85
+ // Copy most headers from the incoming request
86
+ for (const header in req.headers) {
87
+ // Exclude host-specific headers and others that might cause issues upstream
88
+ if (!['host', 'connection', 'content-length', 'transfer-encoding', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions'].includes(header.toLowerCase())) {
89
+ outgoingHeaders[header] = req.headers[header];
90
+ }
91
+ }
92
+
93
+ // Set the actual API key in the appropriate header
94
+ outgoingHeaders['X-Goog-Api-Key'] = apiKey;
95
+
96
+ // Set Content-Type from original request if present (for relevant methods)
97
+ if (req.headers['content-type'] && ['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
98
+ outgoingHeaders['Content-Type'] = req.headers['content-type'];
99
+ } else if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
100
+ // Default Content-Type to application/json if no content type for post/put/patch
101
+ outgoingHeaders['Content-Type'] = 'application/json';
102
+ }
103
+
104
+ // For GET or DELETE requests, ensure Content-Type is NOT sent,
105
+ // even if the client erroneously included it.
106
+ if (['GET', 'DELETE'].includes(req.method.toUpperCase())) {
107
+ delete outgoingHeaders['Content-Type']; // Case-sensitive common practice
108
+ delete outgoingHeaders['content-type']; // Just in case
109
+ }
110
+
111
+ // Ensure 'accept' is reasonable if not set
112
+ if (!outgoingHeaders['accept']) {
113
+ outgoingHeaders['accept'] = '*/*';
114
+ }
115
+
116
+
117
+ const axiosConfig = {
118
+ method: req.method,
119
+ url: apiUrl,
120
+ headers: outgoingHeaders,
121
+ responseType: 'stream',
122
+ validateStatus: function (status) {
123
+ return true; // Accept any status code, we'll pipe it through
124
+ },
125
+ };
126
+
127
+ if (['POST', 'PUT', 'PATCH'].includes(req.method.toUpperCase())) {
128
+ axiosConfig.data = req.body;
129
+ }
130
+ // For GET, DELETE, etc., axiosConfig.data will remain undefined,
131
+ // and axios will not send a request body.
132
+
133
+ const apiResponse = await axios(axiosConfig);
134
+
135
+ // Pass through response headers from Gemini API to the client
136
+ for (const header in apiResponse.headers) {
137
+ res.setHeader(header, apiResponse.headers[header]);
138
+ }
139
+ res.status(apiResponse.status);
140
+
141
+
142
+ apiResponse.data.on('data', (chunk) => {
143
+ res.write(chunk);
144
+ });
145
+
146
+ apiResponse.data.on('end', () => {
147
+ res.end();
148
+ });
149
+
150
+ apiResponse.data.on('error', (err) => {
151
+ console.error('Error during streaming data from target API:', err);
152
+ if (!res.headersSent) {
153
+ res.status(500).json({ error: 'Proxy error during streaming from target' });
154
+ } else {
155
+ // If headers already sent, we can't send a JSON error, just end the response.
156
+ res.end();
157
+ }
158
+ });
159
+
160
+ } catch (error) {
161
+ console.error('Proxy error before request to target API:', error);
162
+ if (!res.headersSent) {
163
+ if (error.response) {
164
+ const errorData = {
165
+ status: error.response.status,
166
+ message: error.response.data?.error?.message || 'Proxy error from upstream API',
167
+ details: error.response.data?.error?.details || null
168
+ };
169
+ res.status(error.response.status).json(errorData);
170
+ } else {
171
+ res.status(500).json({ error: 'Proxy setup error', message: error.message });
172
+ }
173
+ }
174
+ }
175
+ });
176
+
177
+ const webSocketInterceptorScriptTag = `<script src="/public/websocket-interceptor.js" defer></script>`;
178
+
179
+ // Prepare service worker registration script content
180
+ const serviceWorkerRegistrationScript = `
181
+ <script>
182
+ if ('serviceWorker' in navigator) {
183
+ window.addEventListener('load' , () => {
184
+ navigator.serviceWorker.register('./service-worker.js')
185
+ .then(registration => {
186
+ console.log('Service Worker registered successfully with scope:', registration.scope);
187
+ })
188
+ .catch(error => {
189
+ console.error('Service Worker registration failed:', error);
190
+ });
191
+ });
192
+ } else {
193
+ console.log('Service workers are not supported in this browser.');
194
+ }
195
+ </script>
196
+ `;
197
+
198
+ // Serve index.html or placeholder based on API key and file availability
199
+ app.get('/', (req, res) => {
200
+ const placeholderPath = path.join(publicPath, 'placeholder.html');
201
+
202
+ // Try to serve index.html
203
+ console.log("LOG: Route '/' accessed. Attempting to serve index.html.");
204
+ const indexPath = path.join(staticPath, 'index.html');
205
+
206
+ fs.readFile(indexPath, 'utf8', (err, indexHtmlData) => {
207
+ if (err) {
208
+ // index.html not found or unreadable, serve the original placeholder
209
+ console.log('LOG: index.html not found or unreadable. Falling back to original placeholder.');
210
+ return res.sendFile(placeholderPath);
211
+ }
212
+
213
+ // If API key is not set, serve original HTML without injection
214
+ if (!apiKey) {
215
+ console.log("LOG: API key not set. Serving original index.html without script injections.");
216
+ return res.sendFile(indexPath);
217
+ }
218
+
219
+ // index.html found and apiKey set, inject scripts
220
+ console.log("LOG: index.html read successfully. Injecting scripts.");
221
+ let injectedHtml = indexHtmlData;
222
+
223
+
224
+ if (injectedHtml.includes('<head>')) {
225
+ // Inject WebSocket interceptor first, then service worker script
226
+ injectedHtml = injectedHtml.replace(
227
+ '<head>',
228
+ `<head>${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}`
229
+ );
230
+ console.log("LOG: Scripts injected into <head>.");
231
+ } else {
232
+ console.warn("WARNING: <head> tag not found in index.html. Prepending scripts to the beginning of the file as a fallback.");
233
+ injectedHtml = `${webSocketInterceptorScriptTag}${serviceWorkerRegistrationScript}${indexHtmlData}`;
234
+ }
235
+ res.send(injectedHtml);
236
+ });
237
+ });
238
+
239
+ app.get('/service-worker.js', (req, res) => {
240
+ return res.sendFile(path.join(publicPath, 'service-worker.js'));
241
+ });
242
+
243
+ app.use('/public', express.static(publicPath));
244
+ app.use(express.static(staticPath));
245
+
246
+ // Start the HTTP server
247
+ const server = app.listen(port, () => {
248
+ console.log(`Server listening on port ${port}`);
249
+ console.log(`HTTP proxy active on /api-proxy/**`);
250
+ console.log(`WebSocket proxy active on /api-proxy/**`);
251
+ });
252
+
253
+ // Create WebSocket server and attach it to the HTTP server
254
+ const wss = new WebSocket.Server({ noServer: true });
255
+
256
+ server.on('upgrade', (request, socket, head) => {
257
+ const requestUrl = new URL(request.url, `http://${request.headers.host}`);
258
+ const pathname = requestUrl.pathname;
259
+
260
+ if (pathname.startsWith('/api-proxy/')) {
261
+ if (!apiKey) {
262
+ console.error("WebSocket proxy: API key not configured. Closing connection.");
263
+ socket.destroy();
264
+ return;
265
+ }
266
+
267
+ wss.handleUpgrade(request, socket, head, (clientWs) => {
268
+ console.log('Client WebSocket connected to proxy for path:', pathname);
269
+
270
+ const targetPathSegment = pathname.substring('/api-proxy'.length);
271
+ const clientQuery = new URLSearchParams(requestUrl.search);
272
+ clientQuery.set('key', apiKey);
273
+ const targetGeminiWsUrl = `${externalWsBaseUrl}${targetPathSegment}?${clientQuery.toString()}`;
274
+ console.log(`Attempting to connect to target WebSocket: ${targetGeminiWsUrl}`);
275
+
276
+ const geminiWs = new WebSocket(targetGeminiWsUrl, {
277
+ protocol: request.headers['sec-websocket-protocol'],
278
+ });
279
+
280
+ const messageQueue = [];
281
+
282
+ geminiWs.on('open', () => {
283
+ console.log('Proxy connected to Gemini WebSocket');
284
+ // Send any queued messages
285
+ while (messageQueue.length > 0) {
286
+ const message = messageQueue.shift();
287
+ if (geminiWs.readyState === WebSocket.OPEN) {
288
+ // console.log('Sending queued message from client -> Gemini');
289
+ geminiWs.send(message);
290
+ } else {
291
+ // Should not happen if we are in 'open' event, but good for safety
292
+ console.warn('Gemini WebSocket not open when trying to send queued message. Re-queuing.');
293
+ messageQueue.unshift(message); // Add it back to the front
294
+ break; // Stop processing queue for now
295
+ }
296
+ }
297
+ });
298
+
299
+ geminiWs.on('message', (message) => {
300
+ // console.log('Message from Gemini -> client');
301
+ if (clientWs.readyState === WebSocket.OPEN) {
302
+ clientWs.send(message);
303
+ }
304
+ });
305
+
306
+ geminiWs.on('close', (code, reason) => {
307
+ console.log(`Gemini WebSocket closed: ${code} ${reason.toString()}`);
308
+ if (clientWs.readyState === WebSocket.OPEN || clientWs.readyState === WebSocket.CONNECTING) {
309
+ clientWs.close(code, reason.toString());
310
+ }
311
+ });
312
+
313
+ geminiWs.on('error', (error) => {
314
+ console.error('Error on Gemini WebSocket connection:', error);
315
+ if (clientWs.readyState === WebSocket.OPEN || clientWs.readyState === WebSocket.CONNECTING) {
316
+ clientWs.close(1011, 'Upstream WebSocket error');
317
+ }
318
+ });
319
+
320
+ clientWs.on('message', (message) => {
321
+ if (geminiWs.readyState === WebSocket.OPEN) {
322
+ // console.log('Message from client -> Gemini');
323
+ geminiWs.send(message);
324
+ } else if (geminiWs.readyState === WebSocket.CONNECTING) {
325
+ // console.log('Queueing message from client -> Gemini (Gemini still connecting)');
326
+ messageQueue.push(message);
327
+ } else {
328
+ console.warn('Client sent message but Gemini WebSocket is not open or connecting. Message dropped.');
329
+ }
330
+ });
331
+
332
+ clientWs.on('close', (code, reason) => {
333
+ console.log(`Client WebSocket closed: ${code} ${reason.toString()}`);
334
+ if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) {
335
+ geminiWs.close(code, reason.toString());
336
+ }
337
+ });
338
+
339
+ clientWs.on('error', (error) => {
340
+ console.error('Error on client WebSocket connection:', error);
341
+ if (geminiWs.readyState === WebSocket.OPEN || geminiWs.readyState === WebSocket.CONNECTING) {
342
+ geminiWs.close(1011, 'Client WebSocket error');
343
+ }
344
+ });
345
+ });
346
+ } else {
347
+ console.log(`WebSocket upgrade request for non-proxy path: ${pathname}. Closing connection.`);
348
+ socket.destroy();
349
+ }
350
+ });
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "types": [
14
+ "node"
15
+ ],
16
+ "moduleResolution": "bundler",
17
+ "isolatedModules": true,
18
+ "moduleDetection": "force",
19
+ "allowJs": true,
20
+ "jsx": "react-jsx",
21
+ "paths": {
22
+ "@/*": [
23
+ "./*"
24
+ ]
25
+ },
26
+ "allowImportingTsExtensions": true,
27
+ "noEmit": true
28
+ }
29
+ }
vite.config.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ export default defineConfig(({ mode }) => {
6
+ const env = loadEnv(mode, '.', '');
7
+ return {
8
+ server: {
9
+ port: 3000,
10
+ host: '0.0.0.0',
11
+ },
12
+ plugins: [react()],
13
+ define: {
14
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
15
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
16
+ },
17
+ resolve: {
18
+ alias: {
19
+ '@': path.resolve(__dirname, '.'),
20
+ }
21
+ }
22
+ };
23
+ });