@@ -23,6 +23,54 @@ import type { SSEManager } from '../sse/sseManager'
2323import type { VisibilityTracker } from '../visibility/visibilityTracker'
2424import type { Server as BunServer , ServerWebSocket } from 'bun'
2525import type { Server as SocketEngine } from '@socket.io/bun-engine'
26+ import { jwtVerify } from 'jose'
27+
28+ // Gemini Live WebSocket proxy — relays browser WS to Google, bypassing region restrictions
29+ function createGeminiProxyWebSocketHandler ( ) {
30+ const GEMINI_WS_BASE = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
31+ const upstreamMap = new WeakMap < ServerWebSocket < unknown > , WebSocket > ( )
32+
33+ return {
34+ open ( clientWs : ServerWebSocket < unknown > ) {
35+ const data = clientWs . data as { _geminiProxy : boolean ; apiKey : string }
36+ const upstreamUrl = `${ process . env . GEMINI_LIVE_WS_URL || GEMINI_WS_BASE } ?key=${ encodeURIComponent ( data . apiKey ) } `
37+
38+ const upstream = new WebSocket ( upstreamUrl )
39+ upstreamMap . set ( clientWs , upstream )
40+
41+ upstream . onopen = ( ) => {
42+ // Ready — client will send setup message
43+ }
44+ upstream . onmessage = ( event ) => {
45+ try {
46+ if ( clientWs . readyState === 1 ) {
47+ clientWs . send ( typeof event . data === 'string' ? event . data : new Uint8Array ( event . data as ArrayBuffer ) )
48+ }
49+ } catch { /* client gone */ }
50+ }
51+ upstream . onerror = ( ) => {
52+ try { clientWs . close ( 1011 , 'Upstream error' ) } catch { /* */ }
53+ }
54+ upstream . onclose = ( event ) => {
55+ try { clientWs . close ( event . code , event . reason ) } catch { /* */ }
56+ upstreamMap . delete ( clientWs )
57+ }
58+ } ,
59+ message ( clientWs : ServerWebSocket < unknown > , message : string | ArrayBuffer | Uint8Array ) {
60+ const upstream = upstreamMap . get ( clientWs )
61+ if ( upstream ?. readyState === WebSocket . OPEN ) {
62+ upstream . send ( typeof message === 'string' ? message : message )
63+ }
64+ } ,
65+ close ( clientWs : ServerWebSocket < unknown > , code : number , reason : string ) {
66+ const upstream = upstreamMap . get ( clientWs )
67+ if ( upstream ) {
68+ try { upstream . close ( code , reason ) } catch { /* */ }
69+ upstreamMap . delete ( clientWs )
70+ }
71+ }
72+ }
73+ }
2674
2775// Qwen Realtime WebSocket proxy — bridges browser (no custom headers) to DashScope (requires Authorization header)
2876function createQwenProxyWebSocketHandler ( ) {
@@ -284,6 +332,7 @@ export async function startWebServer(options: {
284332
285333 // Wrap socket.io websocket handler to also support Qwen Realtime proxy
286334 const originalWsHandler = socketHandler . websocket
335+ const geminiProxyHandler = createGeminiProxyWebSocketHandler ( )
287336 const qwenProxyHandler = createQwenProxyWebSocketHandler ( )
288337
289338 // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -295,35 +344,70 @@ export async function startWebServer(options: {
295344 websocket : {
296345 ...originalWsHandler ,
297346 open ( ws : unknown ) {
298- const wsAny = ws as ServerWebSocket < { _qwenProxy ?: boolean } >
299- if ( wsAny . data ?. _qwenProxy ) {
347+ const wsAny = ws as ServerWebSocket < { _qwenProxy ?: boolean ; _geminiProxy ?: boolean } >
348+ if ( wsAny . data ?. _geminiProxy ) {
349+ geminiProxyHandler . open ( wsAny )
350+ } else if ( wsAny . data ?. _qwenProxy ) {
300351 qwenProxyHandler . open ( wsAny )
301352 } else {
302353 originalWsHandler . open ?.( ws as never )
303354 }
304355 } ,
305356 message ( ws : unknown , message : unknown ) {
306- const wsAny = ws as ServerWebSocket < { _qwenProxy ?: boolean } >
307- if ( wsAny . data ?. _qwenProxy ) {
357+ const wsAny = ws as ServerWebSocket < { _qwenProxy ?: boolean ; _geminiProxy ?: boolean } >
358+ if ( wsAny . data ?. _geminiProxy ) {
359+ geminiProxyHandler . message ( wsAny , message as string )
360+ } else if ( wsAny . data ?. _qwenProxy ) {
308361 qwenProxyHandler . message ( wsAny , message as string )
309362 } else {
310363 originalWsHandler . message ?.( ws as never , message as never )
311364 }
312365 } ,
313366 close ( ws : unknown , code : number , reason : string ) {
314- const wsAny = ws as ServerWebSocket < { _qwenProxy ?: boolean } >
315- if ( wsAny . data ?. _qwenProxy ) {
367+ const wsAny = ws as ServerWebSocket < { _qwenProxy ?: boolean ; _geminiProxy ?: boolean } >
368+ if ( wsAny . data ?. _geminiProxy ) {
369+ geminiProxyHandler . close ( wsAny , code , reason )
370+ } else if ( wsAny . data ?. _qwenProxy ) {
316371 qwenProxyHandler . close ( wsAny , code , reason )
317372 } else {
318373 originalWsHandler . close ?.( ws as never , code as never , reason as never )
319374 }
320375 }
321376 } ,
322- fetch : ( req : Request , server : { upgrade : ( req : Request , opts ?: unknown ) => boolean } ) => {
377+ fetch : async ( req : Request , server : { upgrade : ( req : Request , opts ?: unknown ) => boolean } ) => {
323378 const url = new URL ( req . url )
324379 if ( url . pathname . startsWith ( '/socket.io/' ) ) {
325380 return socketHandler . fetch ( req , server as never )
326381 }
382+
383+ // Voice WebSocket proxies — require JWT auth via query param
384+ // (browser WebSocket API cannot set custom headers)
385+ if ( url . pathname === '/api/voice/gemini-ws' || url . pathname === '/api/voice/qwen-ws' ) {
386+ const token = url . searchParams . get ( 'token' )
387+ if ( ! token ) {
388+ return new Response ( 'Missing authorization token' , { status : 401 } )
389+ }
390+ try {
391+ await jwtVerify ( token , options . jwtSecret , { algorithms : [ 'HS256' ] } )
392+ } catch {
393+ return new Response ( 'Invalid token' , { status : 401 } )
394+ }
395+ }
396+
397+ // Gemini Live WebSocket proxy
398+ if ( url . pathname === '/api/voice/gemini-ws' ) {
399+ const apiKey = process . env . GEMINI_API_KEY || process . env . GOOGLE_API_KEY
400+ if ( ! apiKey ) {
401+ return new Response ( 'Gemini API key not configured' , { status : 400 } )
402+ }
403+ const upgraded = ( server as unknown as { upgrade : ( req : Request , opts : unknown ) => boolean } ) . upgrade ( req , {
404+ data : { _geminiProxy : true , apiKey }
405+ } )
406+ if ( ! upgraded ) {
407+ return new Response ( 'WebSocket upgrade failed' , { status : 500 } )
408+ }
409+ return undefined as unknown as Response
410+ }
327411 // Qwen Realtime WebSocket proxy
328412 if ( url . pathname === '/api/voice/qwen-ws' ) {
329413 const apiKey = process . env . DASHSCOPE_API_KEY || process . env . QWEN_API_KEY
0 commit comments