Files
hapi/hub/src/web/server.ts
T
wushenghua 2d57763177 fix(hub,cli): run OpenCode usage scans off the process event loop
The 5-minute reconciliation read the multi-GB OpenCode store on the hub's
event loop with bun:sqlite; a cold scan froze every request for ~88s and
stalled each machine's runner the same way.

- scan in a spawned `hapi usage-scan` child process (stdin/stdout JSON)
- refresh on demand from the usage page (debounced) + a daily fallback
- a runner whose hub is localhost skips the store the hub already owns
- machine scans run every 30min and read the message table sequentially
  in one pass instead of per-session indexed lookups
2026-10-16 19:38:07 +08:00

631 lines
27 KiB
TypeScript

import { Hono } from 'hono'
import { compress } from 'hono/compress'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import { serveStatic } from 'hono/bun'
import { getConfiguration } from '../configuration'
import { PROTOCOL_VERSION } from '@hapi/protocol'
import { buildGeminiLiveSetupMessage, QWEN_REALTIME_MODEL } from '@hapi/protocol/voice'
import { getProviderEnvironment } from '../config/providerCredentials'
import { readTitleProviderConfig } from '../sync/titleSuggestion'
import { createQwenProxyWebSocketHandler } from './qwenProxyHandler'
import { decodeVoiceSystemPromptParam } from '../voiceSystemPromptParam'
import type { SyncEngine } from '../sync/syncEngine'
import { createAuthMiddleware, type WebAppEnv } from './middleware/auth'
import { createAuthRoutes } from './routes/auth'
import { createBindRoutes } from './routes/bind'
import { createEventsRoutes } from './routes/events'
import { createSessionsRoutes } from './routes/sessions'
import { createMessagesRoutes } from './routes/messages'
import { createPermissionsRoutes } from './routes/permissions'
import { createMachinesRoutes } from './routes/machines'
import { createStorageRoutes } from './routes/storage'
import { createUsageRoutes } from './routes/usage'
import { createGitRoutes } from './routes/git'
import { createCliRoutes } from './routes/cli'
import { createCodexDesktopRoutes } from './routes/codexDesktop'
import { createClaudeSessionRoutes } from './routes/claudeSessions'
import { createPiSessionRoutes } from './routes/piSessions'
import { createDshSessionRoutes } from './routes/dshSessions'
import { createPushRoutes } from './routes/push'
import { createDevicesRoutes } from './routes/devices'
import { createVoiceRoutes } from './routes/voice'
import { createHubSettingsRoutes } from './routes/hubSettings'
import { createWorkGraphRoutes } from './routes/workGraph'
import { createClaudeModelsRoutes } from './routes/claudeModels'
import { createPublicStudioRoutes, createStudioRoutes } from './routes/studios'
import { createSessionShareRoutes } from './routes/sessionShares'
import { createTeamsRoutes } from './routes/teams'
import type { SSEManager } from '../sse/sseManager'
import type { VisibilityTracker } from '../visibility/visibilityTracker'
import type { TeamService } from '../teams/teamService'
import type { Server as BunServer, ServerWebSocket } from 'bun'
import { applyDefaultWsCompression } from './wsCompression'
import { acceptsGzip } from './sseCompression'
import type { Server as SocketEngine } from '@socket.io/bun-engine'
import { jwtVerify } from 'jose'
import type { WebSocketData } from '@socket.io/bun-engine'
import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets'
import { isBunCompiled } from '../utils/bunCompiled'
import type { Store } from '../store'
// Normalise upstream close codes before forwarding to the browser client.
// Codes 1005/1006/1015 are reserved and cannot be sent in a close frame;
// abnormal upstream drops commonly produce 1006, which would throw on clientWs.close().
function toClientCloseCode(code: number): number {
return code >= 1000 && code <= 4999 && code !== 1005 && code !== 1006 && code !== 1015
? code
: 1011
}
function decodeWsText(message: string | ArrayBuffer | Uint8Array): string {
if (typeof message === 'string') return message
const bytes = message instanceof Uint8Array ? message : new Uint8Array(message)
return new TextDecoder().decode(bytes)
}
function isGeminiSetupFrame(message: string | ArrayBuffer | Uint8Array): boolean {
try {
const parsed = JSON.parse(decodeWsText(message)) as unknown
return parsed !== null && typeof parsed === 'object' && 'setup' in (parsed as object)
} catch {
return false
}
}
function isGeminiSetupCompleteFrame(message: string | ArrayBuffer | Uint8Array): boolean {
try {
const parsed = JSON.parse(decodeWsText(message)) as unknown
return parsed !== null && typeof parsed === 'object' && 'setupComplete' in (parsed as object)
} catch {
return false
}
}
const MAX_GEMINI_PENDING_BYTES = 1024 * 1024 // 1 MiB — rejects setup-window floods
function frameByteSize(msg: string | ArrayBuffer | Uint8Array): number {
return typeof msg === 'string' ? msg.length : (msg as ArrayBuffer | Uint8Array).byteLength
}
// Gemini Live WebSocket proxy — relays browser WS to Google, bypassing region restrictions
function createGeminiProxyWebSocketHandler() {
const GEMINI_WS_BASE = 'wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'
const upstreamMap = new WeakMap<ServerWebSocket<unknown>, WebSocket>()
// pendingMap holds queued client frames until Google acknowledges setup via setupComplete.
// Flushed on setupComplete; until then message() queues rather than forwards.
const pendingMap = new WeakMap<ServerWebSocket<unknown>, Array<string | ArrayBuffer | Uint8Array>>()
const pendingBytesMap = new WeakMap<ServerWebSocket<unknown>, number>()
return {
open(clientWs: ServerWebSocket<unknown>) {
const data = clientWs.data as {
_geminiProxy: boolean
apiKey: string
language?: string
voiceName?: string
systemInstruction?: string
affectiveDialog?: boolean
}
const upstreamUrl = `${process.env.GEMINI_LIVE_WS_URL || GEMINI_WS_BASE}?key=${encodeURIComponent(data.apiKey)}`
const pending: Array<string | ArrayBuffer | Uint8Array> = []
pendingMap.set(clientWs, pending)
pendingBytesMap.set(clientWs, 0)
const upstream = new WebSocket(upstreamUrl)
upstreamMap.set(clientWs, upstream)
upstream.onopen = () => {
// Hub-owned setup only — never forward client setup (prevents generic Gemini proxy abuse).
// Do NOT flush pending here: wait for Google's setupComplete before forwarding client frames.
upstream.send(JSON.stringify(buildGeminiLiveSetupMessage(
data.language,
data.voiceName,
data.systemInstruction,
{ affectiveDialog: data.affectiveDialog }
)))
}
upstream.onmessage = (event) => {
try {
if (clientWs.readyState === 1) {
clientWs.send(typeof event.data === 'string' ? event.data : new Uint8Array(event.data as ArrayBuffer))
}
} catch { /* client gone */ }
// Flush queued client frames only after Google acknowledges setup.
const pending = pendingMap.get(clientWs)
if (pending && isGeminiSetupCompleteFrame(event.data as string | ArrayBuffer)) {
pendingMap.delete(clientWs)
pendingBytesMap.delete(clientWs)
for (const queued of pending) {
try { upstream.send(queued) } catch { /* upstream gone */ }
}
}
}
upstream.onerror = () => {
pendingMap.delete(clientWs)
pendingBytesMap.delete(clientWs)
try { clientWs.close(1011, 'Upstream error') } catch { /* */ }
}
upstream.onclose = (event) => {
pendingMap.delete(clientWs)
pendingBytesMap.delete(clientWs)
try { clientWs.close(toClientCloseCode(event.code), event.reason || 'Upstream closed') } catch { /* client gone */ }
upstreamMap.delete(clientWs)
}
},
message(clientWs: ServerWebSocket<unknown>, message: string | ArrayBuffer | Uint8Array) {
if (isGeminiSetupFrame(message)) {
try { clientWs.close(1008, 'Client-provided Gemini setup is not allowed') } catch { /* */ }
return
}
const upstream = upstreamMap.get(clientWs)
const pending = pendingMap.get(clientWs)
if (pending) {
// Still awaiting setupComplete — queue, but cap to prevent setup-window floods.
const total = (pendingBytesMap.get(clientWs) ?? 0) + frameByteSize(message)
if (total > MAX_GEMINI_PENDING_BYTES) {
try { clientWs.close(1009, 'Setup-window frame budget exceeded') } catch { /* */ }
return
}
pendingBytesMap.set(clientWs, total)
pending.push(message)
} else if (upstream?.readyState === WebSocket.OPEN) {
upstream.send(message)
}
},
close(clientWs: ServerWebSocket<unknown>, code: number, reason: string) {
const upstream = upstreamMap.get(clientWs)
pendingMap.delete(clientWs)
pendingBytesMap.delete(clientWs)
if (upstream) {
try { upstream.close(toClientCloseCode(code), (reason || 'Client closed').slice(0, 123)) } catch { /* */ }
upstreamMap.delete(clientWs)
}
}
}
}
// Qwen Realtime WebSocket proxy — bridges browser (no custom headers) to DashScope
// (requires Authorization header). Implementation extracted to `./qwenProxyHandler` so
// the ack-gating behaviour is unit-testable; `createQwenProxyWebSocketHandler` is imported above.
function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
const candidates = [
join(process.cwd(), '..', 'web', 'dist'),
join(import.meta.dir, '..', '..', '..', 'web', 'dist'),
join(process.cwd(), 'web', 'dist')
]
for (const distDir of candidates) {
const indexHtmlPath = join(distDir, 'index.html')
if (existsSync(indexHtmlPath)) {
return { distDir, indexHtmlPath }
}
}
const distDir = candidates[0]
return { distDir, indexHtmlPath: join(distDir, 'index.html') }
}
function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response {
const headers: Record<string, string> = {
'Content-Type': asset.mimeType
}
if (asset.path === '/sw.js') {
headers['Cache-Control'] = 'no-store, no-cache, must-revalidate'
headers['CDN-Cache-Control'] = 'no-store'
headers['Cloudflare-CDN-Cache-Control'] = 'no-store'
} else if (asset.path.startsWith('/studio-assets/')) {
headers['Cache-Control'] = 'public, max-age=31536000, immutable'
}
return new Response(Bun.file(asset.sourcePath), {
headers
})
}
function createWebApp(options: {
getSyncEngine: () => SyncEngine | null
getSseManager: () => SSEManager | null
getVisibilityTracker: () => VisibilityTracker | null
getTeamService?: () => TeamService | null
jwtSecret: Uint8Array
store: Store
vapidPublicKey: string
corsOrigins?: string[]
embeddedAssetMap: Map<string, EmbeddedWebAsset> | null
relayMode?: boolean
officialWebUrl?: string
dataDir: string
refreshUsage?: () => Promise<void>
}): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
app.use('*', logger())
const configuration = getConfiguration()
const corsOrigins = options.corsOrigins ?? configuration.corsOrigins
const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins
const corsMiddleware = cors({
origin: corsOriginOption,
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
// last-event-id: browsers attach it to EventSource reconnects for
// SSE replay; allow it in case a browser preflights the request.
allowHeaders: ['authorization', 'content-type', 'last-event-id']
})
app.use('/health', corsMiddleware)
app.use('/api/*', corsMiddleware)
app.use('/cli/*', corsMiddleware)
// Health check endpoint (no auth required).
// Capabilities are additive so older clients can ignore unknown fields.
app.get('/health', (c) => c.json({
status: 'ok',
protocolVersion: PROTOCOL_VERSION,
capabilities: {
workGraph: true,
titleSuggestion: readTitleProviderConfig() !== null
}
}))
// Gzip JSON API responses. Over the relay tunnel every byte is metered
// twice (the SNI proxy copies in both directions), and API payloads are
// repetitive JSON that compresses to roughly a quarter of its size.
//
// This deliberately does not touch /api/events: streamSSE sets
// Transfer-Encoding, which hono's compress() skips, and that stream is
// already gzipped by compressSseResponse with an explicit sync flush.
// Binary uploads/downloads are skipped too - compress() only handles
// content types it knows are compressible.
//
// Gated on the q-aware parser because hono's compress() matches the
// Accept-Encoding value by substring: `gzip;q=0` - an explicit refusal -
// would otherwise still get a gzip body it cannot consume.
const gzipCompress = compress({ encoding: 'gzip' })
app.use('/api/*', async (c, next) => {
if (acceptsGzip(c.req.header('Accept-Encoding'))) {
return gzipCompress(c, next)
}
return next()
})
app.use('/studio-assets/*', async (c, next) => {
c.header('Cache-Control', 'public, max-age=31536000, immutable')
if (acceptsGzip(c.req.header('Accept-Encoding'))) {
return gzipCompress(c, next)
}
return next()
})
app.route('/cli', createCliRoutes(options.getSyncEngine))
app.route('/api', createAuthRoutes(options.jwtSecret, options.store))
app.route('/api', createBindRoutes(options.jwtSecret, options.store))
app.route('/api', createPublicStudioRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.use('/api/*', createAuthMiddleware(options.jwtSecret, {
isGuestTokenActive: (shareToken) => options.store.sessionShares.getActiveByToken(shareToken) !== null,
resolveTeamAgentToken: (token) => options.getTeamService?.()?.resolveAgentToken(token) ?? null
}))
app.route('/api', createSessionShareRoutes({ store: options.store, jwtSecret: options.jwtSecret, getSyncEngine: options.getSyncEngine }))
app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker, {
isGuestTokenActive: (shareToken) => options.store.sessionShares.getActiveByToken(shareToken) !== null
}))
app.route('/api', createSessionsRoutes(options.getSyncEngine))
app.route('/api', createMessagesRoutes(options.getSyncEngine))
app.route('/api', createPermissionsRoutes(options.getSyncEngine))
app.route('/api', createMachinesRoutes(options.getSyncEngine, options.getTeamService))
app.route('/api', createStorageRoutes(configuration.dbPath))
app.route('/api', createHubSettingsRoutes(configuration.dataDir, () => configuration.teamsEnabled))
const teamService = options.getTeamService?.() ?? null
if (teamService) {
app.route('/api', createTeamsRoutes(teamService))
}
app.route('/api', createUsageRoutes(options.store, options.refreshUsage))
app.route('/api', createStudioRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.route('/api', createGitRoutes(options.getSyncEngine))
// 中文注释:这里提供两类 Codex 辅助能力:扫描本地 transcript 以导入到 Hapi,以及按需重启 Codex Desktop 客户端。
app.route('/api', createCodexDesktopRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.route('/api', createClaudeSessionRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.route('/api', createPiSessionRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.route('/api', createDshSessionRoutes({
store: options.store,
getSyncEngine: options.getSyncEngine
}))
app.route('/api', createPushRoutes(options.store, options.vapidPublicKey))
app.route('/api', createClaudeModelsRoutes(options.dataDir))
app.route('/api', createDevicesRoutes(options.store))
app.route('/api', createVoiceRoutes({ dataDir: configuration.dataDir }))
// Path is intentionally NOT `/api/events` — that route is the SSE stream.
app.route('/api', createWorkGraphRoutes(options.store))
// Skip static serving in relay mode, show helpful message on root
if (options.relayMode) {
const officialUrl = options.officialWebUrl || 'https://app.hapi.run'
app.get('/', (c) => {
return c.html(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>HAPI Hub</title></head>
<body style="font-family: system-ui; padding: 2rem; max-width: 600px;">
<h1>HAPI Hub</h1>
<p>This hub is running in relay mode. Please use the official web app:</p>
<p><a href="${officialUrl}">${officialUrl}</a></p>
<details>
<summary>Why am I seeing this?</summary>
<p style="margin-top: 0.5rem; color: #666;">
When relay mode is enabled, all traffic flows through our relay infrastructure with end-to-end encryption.
To reduce bandwidth and improve performance, the frontend is served separately
from GitHub Pages instead of through the relay tunnel.
</p>
</details>
</body>
</html>`)
})
return app
}
if (options.embeddedAssetMap) {
const embeddedAssetMap = options.embeddedAssetMap
const indexHtmlAsset = embeddedAssetMap.get('/index.html')
const studioHtmlAsset = embeddedAssetMap.get('/studio.html')
if (!indexHtmlAsset) {
app.get('*', (c) => {
return c.text(
'Embedded Mini App is missing index.html. Rebuild the executable after running bun run build:web.',
503
)
})
return app
}
app.use('*', async (c, next) => {
if (c.req.path.startsWith('/api')) {
return await next()
}
if (c.req.path.startsWith('/studio/') && studioHtmlAsset) {
return serveEmbeddedAsset(studioHtmlAsset)
}
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
return await next()
}
const asset = embeddedAssetMap.get(c.req.path)
if (asset) {
return serveEmbeddedAsset(asset)
}
return await next()
})
app.get('*', async (c, next) => {
if (c.req.path.startsWith('/api')) {
await next()
return
}
return serveEmbeddedAsset(indexHtmlAsset)
})
return app
}
const { distDir, indexHtmlPath } = findWebappDistDir()
const studioHtmlPath = join(distDir, 'studio.html')
if (!existsSync(indexHtmlPath)) {
app.get('/', (c) => {
return c.text(
'Mini App is not built.\n\nRun:\n cd web\n bun install\n bun run build\n',
503
)
})
return app
}
app.use('/assets/*', serveStatic({ root: distDir }))
app.use('/studio-assets/*', serveStatic({ root: distDir }))
if (existsSync(studioHtmlPath)) {
app.get('/studio/*', async (c) => {
return await serveStatic({ root: distDir, path: 'studio.html' })(c, async () => {})
})
}
app.use('*', async (c, next) => {
if (c.req.path.startsWith('/api')) {
await next()
return
}
return await serveStatic({ root: distDir })(c, next)
})
app.get('*', async (c, next) => {
if (c.req.path.startsWith('/api')) {
await next()
return
}
return await serveStatic({ root: distDir, path: 'index.html' })(c, next)
})
return app
}
export async function startWebServer(options: {
getSyncEngine: () => SyncEngine | null
getSseManager: () => SSEManager | null
getVisibilityTracker: () => VisibilityTracker | null
getTeamService?: () => TeamService | null
jwtSecret: Uint8Array
store: Store
vapidPublicKey: string
socketEngine: SocketEngine
corsOrigins?: string[]
relayMode?: boolean
officialWebUrl?: string
dataDir: string
refreshUsage?: () => Promise<void>
}): Promise<BunServer<WebSocketData>> {
const isCompiled = isBunCompiled()
const embeddedAssetMap = isCompiled ? await loadEmbeddedAssetMap() : null
const app = createWebApp({
getSyncEngine: options.getSyncEngine,
getSseManager: options.getSseManager,
getVisibilityTracker: options.getVisibilityTracker,
getTeamService: options.getTeamService,
jwtSecret: options.jwtSecret,
store: options.store,
vapidPublicKey: options.vapidPublicKey,
corsOrigins: options.corsOrigins,
embeddedAssetMap,
dataDir: options.dataDir,
relayMode: options.relayMode,
officialWebUrl: options.officialWebUrl,
refreshUsage: options.refreshUsage
})
const configuration = getConfiguration()
const socketHandler = options.socketEngine.handler()
// Wrap socket.io websocket handler to also support Gemini/Qwen proxy connections
const originalWsHandler = socketHandler.websocket
const geminiProxyHandler = createGeminiProxyWebSocketHandler()
const qwenProxyHandler = createQwenProxyWebSocketHandler()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const server = (Bun.serve as any)({
hostname: configuration.listenHost,
port: configuration.listenPort,
// Media downloads wait on a hub→CLI RPC that can take minutes on a slow
// runner tunnel while zero bytes flow to the client. Keep the HTTP
// connection alive well past the socket idle default (Bun caps at 255s).
idleTimeout: Math.min(255, Math.max(30, socketHandler.idleTimeout, 240)),
maxRequestBodySize: Math.max(socketHandler.maxRequestBodySize, 68 * 1024 * 1024),
websocket: {
...originalWsHandler,
// Advertise permessage-deflate. Negotiation alone compresses
// nothing in Bun — each send() opts in — so open() below also
// makes compression the default for flagless sends. See
// wsCompression.ts for the contract.
perMessageDeflate: true,
open(ws: unknown) {
applyDefaultWsCompression(ws as ServerWebSocket<unknown>)
const wsAny = ws as ServerWebSocket<{ _qwenProxy?: boolean; _geminiProxy?: boolean }>
if (wsAny.data?._geminiProxy) {
geminiProxyHandler.open(wsAny)
} else if (wsAny.data?._qwenProxy) {
qwenProxyHandler.open(wsAny)
} else {
originalWsHandler.open?.(ws as never)
}
},
message(ws: unknown, message: unknown) {
const wsAny = ws as ServerWebSocket<{ _qwenProxy?: boolean; _geminiProxy?: boolean }>
if (wsAny.data?._geminiProxy) {
geminiProxyHandler.message(wsAny, message as string)
} else if (wsAny.data?._qwenProxy) {
qwenProxyHandler.message(wsAny, message as string)
} else {
originalWsHandler.message?.(ws as never, message as never)
}
},
close(ws: unknown, code: number, reason: string) {
const wsAny = ws as ServerWebSocket<{ _qwenProxy?: boolean; _geminiProxy?: boolean }>
if (wsAny.data?._geminiProxy) {
geminiProxyHandler.close(wsAny, code, reason)
} else if (wsAny.data?._qwenProxy) {
qwenProxyHandler.close(wsAny, code, reason)
} else {
originalWsHandler.close?.(ws as never, code as never, reason as never)
}
}
},
fetch: async (req: Request, server: { upgrade: (req: Request, opts?: unknown) => boolean }) => {
const url = new URL(req.url)
if (url.pathname.startsWith('/socket.io/')) {
return socketHandler.fetch(req, server as never)
}
// Voice WebSocket proxies — require JWT auth via query param
// (browser WebSocket API cannot set custom headers)
if (url.pathname === '/api/voice/gemini-ws' || url.pathname === '/api/voice/qwen-ws') {
const token = url.searchParams.get('token')
if (!token) {
return new Response('Missing authorization token', { status: 401 })
}
try {
await jwtVerify(token, options.jwtSecret, { algorithms: ['HS256'] })
} catch {
return new Response('Invalid token', { status: 401 })
}
}
// Gemini Live WebSocket proxy
if (url.pathname === '/api/voice/gemini-ws') {
const env = getProviderEnvironment()
const apiKey = env.GEMINI_API_KEY || env.GOOGLE_API_KEY
if (!apiKey) {
return new Response('Gemini API key not configured', { status: 400 })
}
const language = url.searchParams.get('language') ?? undefined
const voiceParam = url.searchParams.get('voice')?.trim() || undefined
const systemInstruction = decodeVoiceSystemPromptParam(url.searchParams.get('systemPrompt'))
const affectiveDialog = url.searchParams.get('affectiveDialog') === '1'
const upgraded = (server as unknown as { upgrade: (req: Request, opts: unknown) => boolean }).upgrade(req, {
data: { _geminiProxy: true, apiKey, language, voiceName: voiceParam, systemInstruction, affectiveDialog }
})
if (!upgraded) {
return new Response('WebSocket upgrade failed', { status: 500 })
}
return undefined as unknown as Response
}
// Qwen Realtime WebSocket proxy
if (url.pathname === '/api/voice/qwen-ws') {
const env = getProviderEnvironment()
const apiKey = env.DASHSCOPE_API_KEY || env.QWEN_API_KEY
const model = QWEN_REALTIME_MODEL
const language = url.searchParams.get('language') ?? undefined
const voiceParam = url.searchParams.get('voice')?.trim() || undefined
const systemInstruction = decodeVoiceSystemPromptParam(url.searchParams.get('systemPrompt'))
if (!apiKey) {
return new Response('DashScope API key not configured', { status: 400 })
}
const upgraded = (server as unknown as { upgrade: (req: Request, opts: unknown) => boolean }).upgrade(req, {
data: { _qwenProxy: true, apiKey, model, language, voiceName: voiceParam, systemInstruction }
})
if (!upgraded) {
return new Response('WebSocket upgrade failed', { status: 500 })
}
return undefined as unknown as Response
}
return app.fetch(req)
}
})
console.log(`[Web] hub listening on ${configuration.listenHost}:${configuration.listenPort}`)
console.log(`[Web] public URL: ${configuration.publicUrl}`)
return server
}