Files
hapi/hub/src/web/server.ts
T
226b2d066a feat(hub): native companion (FCM) push channel + device registry + pairing QR (#803)
* feat(hub): native companion (FCM) push channel + device registry

Adds opt-in FCM HTTP v1 notification delivery so a companion mobile/wearable
app can receive permission, ready, and task notifications end-to-end. The
channel is gated entirely on FCM_SERVICE_ACCOUNT_PATH + FCM_PROJECT_ID being
set; operators not running a companion see zero behavior change.

What lands:

- POST/DELETE /api/devices/register — JWT-authed FCM token registry,
  upsert on (namespace, deviceId, platform), platforms `phone` | `wear`.
- Sqlite v9 → v10 migration adds `fcm_devices` (idx on namespace + token).
- FcmService — minimal HTTP v1 client, RS256 service-account JWT via
  jose (dep already in tree), 5-minute access-token cache, 401 retry.
- FcmNotificationChannel — implements NotificationChannel, sends data-only
  FCM (so companion can route to phone+watch surfaces). Body composition
  parses an optional trailing `AGENT_NOTIFY_SUMMARY {json}` line for richer
  ready summaries; truncates plain assistant text to 280 chars otherwise.
  Tags each payload with `severity` (info/warning/success/error) so clients
  can color/categorise the notification.
- PushNotificationChannel gains a NativeFallbackProbe — when a namespace
  has at least one registered FCM device, web-push and SSE in-page toast
  are skipped so the operator does not double-notify on phone+browser.
  Probe is no-op when no FCM device is registered; PWA-only setups
  unchanged. Branch trace gated on HAPI_NOTIFY_DEBUG=1.
- shared/src/messages.ts — `extractAssistantPlainText` (codex + Claude SDK
  shapes) and `extractNotifySummary` (strict end-anchored line parser).
- hub/src/notifications/toolArgs.ts — tool-arg formatters lifted out of
  telegram/sessionView (kept duplicated there in this PR; refactor of
  Telegram is a follow-up).
- docs/api/native-companion-contract.md — payload + endpoints + env vars,
  versioned at contract v1.

Test coverage:

- 260 hub tests pass (incl. 23 new across FCM channel, push dedup,
  v10 migration, devices route).
- 60 shared tests pass (messages parsers).

Notes for reviewers:

- Reference companion implementation lives in a separate Android repo
  (Kotlin, phone APK + Wear OS APK) — this PR is hub-side only.
- No new runtime deps (`jose` and `zod` already declared in hub).

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(contract): clarify scope - companion is remote-hub client, not hub-on-phone

Adds a Scope section to the native-companion contract so anyone
implementing it knows the audience: operators running the hub on a
server who want phone/watch as a notification surface, not users
expecting a Termux-bundled hub. Mirrors the framing now in
heavygee/hapi-companion README.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(contract): correct Scope section - hub topology is unchanged

Removes the prior framing that referenced a non-existent 'Termux
hub-on-phone' alternative. This contract describes a native client to
the same hub the PWA talks to; it does not change where the hub runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): companion app pairing QR in Settings

Companion section in Settings renders a QR code encoding the deeplink
hapicompanion://bind?hub=<base>&code=<token>. Scanning it from the HAPI
companion app (Android phone or Wear OS) auto-fills the bind form and
authenticates against this hub - no manual URL/token paste.

QR is gated behind a Show button so the access token doesn't sit visible
on screen by default; a Copy link affordance and the textual deeplink
are also exposed for manual onboarding.

Adds qrcode + @types/qrcode to web/ (already a hub dep, no new resolved
package - just a workspace declaration).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(hub): terminal QR for companion app pairing alongside PWA QR

After the existing PWA access QR is rendered on tunnel start, also print
the hapicompanion://bind?hub=...&code=... deeplink and a matching QR.

Same tunnel + token, different scheme: phones with the companion app
installed pick up the deeplink via the manifest intent filter; phones
without it ignore it and fall back to the PWA QR above.

QR rendering failure is non-fatal in both cases - the textual deeplink
above the QR is sufficient for manual paste.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(fcm): address HAPI Bot review on PR #803

Two bugs surfaced by the upstream review bot:

1) Web Push silently dropped when FCM is not actually configured.
   The native-fallback probe only checked the device registry; it did
   not check whether resolveFcmConfig() actually succeeded. So an
   operator who previously enabled FCM, registered a phone, then later
   started the hub WITHOUT FCM_SERVICE_ACCOUNT_PATH would see the probe
   return true (devices still in DB) -> Web Push suppressed -> no FCM
   channel registered -> notifications go to /dev/null.

   Fix: extracted the probe construction into buildNativeFallbackProbe()
   which short-circuits to () => false when fcmConfig is missing. Probe
   never even consults the device store in the no-config branch, so
   stale rows can never matter.

2) Transient FCM failures permanently unregistered devices.
   sendToToken() returned a single boolean and sendToNamespace() removed
   any device whose send returned false. A 429 (rate limit), 503
   (server error), 401 (auth glitch), or even an ECONNREFUSED would
   delete the device row, after which the user would need to re-pair to
   get notifications again. The bot caught it; the fix is the obvious
   one.

   Fix: sendToToken() now returns 'sent' | 'invalid' | 'failed'.
   - 'invalid' is reserved for the responses that genuinely indicate a
     dead token: HTTP 404 with UNREGISTERED/NOT_FOUND, and HTTP 400
     with INVALID_ARGUMENT explicitly referencing the token field.
   - Everything else (429, 5xx, 401, 403, network errors) is 'failed'
     and counts toward the failed tally without removing the device.

   sendToNamespace() only calls removeDeviceByToken() on 'invalid'.

Tests: 11 new tests across two new files. fcmService.test.ts covers
all six branches (200, 404 unregistered, 429, 503, 401, network error)
plus a mixed-batch case that proves invalid tokens get removed in the
same call where transient-failure tokens survive. nativeFallbackProbe
.test.ts covers both no-config and configured branches plus the
explicit "no-config never touches the store" guarantee.

Hub test count: 273 -> 284 (all passing).

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(contract): correct FCM visibility rule and remove unsupported event type

HAPI Bot review on PR #803 caught two contract-doc accuracy gaps:

1) Visibility rule was wrong. Doc said "FCM fires when Web Push would
   fire AND client not visible via SSE", but FcmNotificationChannel
   ALWAYS fires regardless of PWA visibility (deliberately - native
   companion is the canonical wrist-first surface, and there is a
   passing test asserting this). Companion app implementers reading
   the contract would have built foreground-suppression logic and
   then dropped notifications when the PWA tab was open.

2) Documented `session-completed` event doesn't exist. NotificationHub
   never calls into a 'session-completed' channel method on
   FcmNotificationChannel; the type would never reach a native client.
   Removed from the documented enum, leaving only the three actual
   events: ready, permission-request, task-notification.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(contract): drop trailing whitespace, use blank line for paragraph break

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): persist CLI access token after Telegram bind so pairing QR works

The Settings -> Companion pairing QR reads the original CLI access token
from localStorage (hapi_access_token::<baseUrl>) so it can be encoded into
the hapicompanion://bind deeplink. For browser/CLI logins useAuthSource
already persists the token via setAccessToken, but the Telegram Mini App
bind path went through useAuth.bind() which exchanged the typed CLI token
for a JWT and never persisted it. Telegram users therefore always saw the
"signed in via Telegram..." fallback and got no usable QR.

After a successful client.bind() we now mirror useAuthSource's behavior
and write the same accessToken to the same localStorage key, restoring
parity between the two auth paths. No change for browser/CLI users.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(fcm): gate native-fallback probe on rolling FCM health

The native-fallback probe previously returned true whenever FCM was
configured AND devices were registered, which suppressed web-push for
the namespace. The HAPI Bot correctly pointed out the gap: if the FCM
pipeline silently breaks (expired service-account key, sustained 5xx,
OAuth token-fetch failure, network blackhole) the operator gets nothing
on either channel until they manually intervene.

Approach (deliberate, not the bot's exact suggested fix):

- FcmService now keeps a small rolling window (last 8 outcomes) of send
  attempts and exposes `isHealthy()`. The threshold is 5+/8 failures =
  unhealthy; the buffer starts empty so a freshly-booted hub is
  optimistic ("innocent until proven guilty") and does not double-fire
  on event #1.
- Token-fetch failure (`getFcmAccessToken` throws) now records exactly
  one health-failure (not one per device), short-circuits the send
  loop, and returns a result so `sendToNamespace` no longer leaks the
  exception.
- `invalid` token responses are explicitly excluded from the health
  buffer because they are per-device facts (rotated/uninstalled token),
  not pipeline failures - FCM was reachable, it just rejected one
  stale token.
- `buildNativeFallbackProbe` now optionally accepts the FcmService and
  short-circuits to "let web-push fire" when health is bad, before it
  even queries the device registry. The single-arg call shape is still
  supported for back-compat.

Why not the bot's exact suggestion ("invert: call FCM first, fall back
on result.sent === 0"):
- Couples PushNotificationChannel to FcmService and FcmSendPayload,
  reversing the clean parallel-channel architecture established earlier
  in this PR.
- Treats every transient single-event failure as fallback-worthy, which
  re-opens the duplicate-notification race that the suppression logic
  was added to close (FCM HTTP timeout that delivers later + the web
  push we sent in the meantime = two pings).
- A rolling health window only flips on sustained breakage, which is
  the actual operational scenario the bot is worried about.

The wrist-first design intent ("FCM fires unconditionally, web-push is
suppressed for the same namespace") documented in
docs/api/native-companion-contract.md is preserved on the happy path.
The probe only re-enables web-push when there is concrete evidence the
native pipeline is not delivering.

Tests:
- New FcmService.isHealthy suite covers empty-buffer, threshold flip,
  recovery as failures age out of the window, invalid-token exclusion,
  and network-error path.
- nativeFallbackProbe gains coverage for the unhealthy-but-registered,
  healthy-and-registered, and absent-fcmService (back-compat) cases.
- All 292 hub tests still pass; typecheck clean.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(telegram): drop duplicate tool-args formatter, use shared module

The Telegram session view had its own copy of formatToolArgumentsDetailed
identical to the one in hub/src/notifications/toolArgs.ts (already used by
the FCM channel). Replace the local copy with an import.

Removes ~70 lines of duplication, plus the now-unused MAX_TOOL_ARGS_LENGTH
constant and `truncate` import. The shared signature accepts an optional
opts arg whose default maxArgLength is 150 - matching the prior constant -
so the call site is unchanged.

Two benign upgrades come along for the ride from the shared module:
?? instead of || on field fallbacks (no real-world difference; permission
arguments never carry empty-string fields), and String(...) wrapping plus
a typeof object guard that makes non-string values render gracefully
instead of throwing into the catch block.

Hub tests: 311 pass / 0 fail. Telegram subset: 5 pass / 0 fail. typecheck
green.

Cold-reviewed by an out-of-context Claude Opus peer before push.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(fcm): require positive evidence in health window before suppressing web-push

Addresses HAPI Bot Major review on PR #803.

The previous health gate treated an empty outcome buffer as healthy
("innocent until proven guilty"). That created a silent-blackhole window
on cold start with broken FCM credentials: the push channel suppressed
SSE/Web Push for the first ~5 events while the FCM channel attempted
each delivery and recorded failures, until enough stacked to flip the
threshold. Every notification in that gap was silently lost.

New invariant: isHealthy() requires at least one successful FCM send in
the recent window (HEALTH_WINDOW=8) AND failures below threshold
(HEALTH_FAILURE_THRESHOLD=5). Both conditions are necessary; either
alone is insufficient evidence to safely suppress web-push fallback.

Trade-off: one duplicated notification per hub restart per namespace.
On the first event after restart, web-push fires alongside FCM (because
the gate has no positive evidence yet). Once FCM records that first
success, the gate engages and subsequent events are FCM-only. Worth it
for guaranteed delivery during cold-start outages.

Tests reworked to match new semantics:
- "starts UNHEALTHY with empty buffer" (was: healthy)
- "flips to healthy after first successful send" (new)
- "stays unhealthy across failures-only run" (new, exercises the exact
  blackhole scenario the bot flagged)
- "flips back to unhealthy after threshold breach with prior successes"
  (renamed, establishes successes first)
- "invalid tokens don't count against health" (reworked: send a mixed
  batch first to establish health, then verify invalids don't flip it)
- "network errors count as failures" (reworked: establish health first)

Hub tests: 313 pass / 0 fail. typecheck green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): bump FCM migration to V10→V11 after upstream service_tier V9→V10

Upstream/main landed sessions.service_tier at schema v10. The companion
FCM device registry now migrates at v11 so both changes compose cleanly
after the courtesy rebase onto current upstream/main.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): per-dispatch native gate instead of stale FCM probe

FCM runs before web-push; PushNotificationChannel skips web/SSE only
when the same notify() dispatch already delivered via FCM. Removes the
isHealthy()+device-row probe that could suppress web-push after warm
FCM outages.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub,web): cap notifySummary for FCM limits; fix PWA test cast

Rebase follow-up: truncate AGENT_NOTIFY_SUMMARY summary/action before
FCM data payload (bot Major). Fix usePwaUpdate.test.ts setTimeout mock
cast so bun typecheck passes on current main.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): cap all FCM notifySummary fields and task bodies

Whitelist and truncate AGENT_NOTIFY_SUMMARY auxiliary fields before
JSON serialization; cap task-notification summaries to glance limit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): FCM fetch timeouts and cap Grep/Glob permission args

10s AbortSignal.timeout on OAuth + FCM send so sequential web-push
fallback is not blocked on hung Google endpoints; truncate Grep/Glob
pattern in permission detail formatter.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): bind FCM token to one namespace on re-pair

Delete stale fcm_devices rows sharing the same token when a native
install registers under a different namespace.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): localize Companion settings and pairing copy

Add en/zh-CN keys for the Companion section title and CompanionPairing
strings; matches locale-driven Settings pattern (bot Minor on #803).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): tighten FCM token-invalid detection and truncation edge cases

Parse FCM error JSON: only UNREGISTERED or token-field INVALID_ARGUMENT
unregister devices; generic NOT_FOUND stays transient. Guard limit<=3
in truncateReadyText so tiny action budgets cannot blow the glance cap.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(hub): parse FcmError details.errorCode for UNREGISTERED tokens

FCM v1 often returns HTTP 404 with root NOT_FOUND plus
details[].errorCode UNREGISTERED; prune those tokens while keeping
generic project/resource NOT_FOUND transient.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): mock AppContext for About Companion pairing in settings tests

Settings About now mounts CompanionPairing via useAppContext after the
#1027 hub redesign rebase; wrap the About route test with AppContext and
Companion mocks so the suite stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(contract): point companion auth at POST /api/auth, not /api/bind

Pairing QR carries the CLI access token as `code`. /api/bind requires
Telegram initData; native companions must use /api/auth with accessToken.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): mount Companion pairing under Settings General

About is version/links only after the settings hub redesign; pairing is
setup, so keep Companion with language prefs and update the route tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
2026-07-27 19:52:54 +08:00

504 lines
21 KiB
TypeScript

import { Hono } from 'hono'
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 { 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 { createGitRoutes } from './routes/git'
import { createCliRoutes } from './routes/cli'
import { createCodexDesktopRoutes } from './routes/codexDesktop'
import { createPushRoutes } from './routes/push'
import { createDevicesRoutes } from './routes/devices'
import { createVoiceRoutes } from './routes/voice'
import type { SSEManager } from '../sse/sseManager'
import type { VisibilityTracker } from '../visibility/visibilityTracker'
import type { Server as BunServer, ServerWebSocket } from 'bun'
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'
}
return new Response(Bun.file(asset.sourcePath), {
headers
})
}
function createWebApp(options: {
getSyncEngine: () => SyncEngine | null
getSseManager: () => SSEManager | null
getVisibilityTracker: () => VisibilityTracker | null
jwtSecret: Uint8Array
store: Store
vapidPublicKey: string
corsOrigins?: string[]
embeddedAssetMap: Map<string, EmbeddedWebAsset> | null
relayMode?: boolean
officialWebUrl?: string
}): Hono<WebAppEnv> {
const app = new Hono<WebAppEnv>()
app.use('*', logger())
// Health check endpoint (no auth required)
app.get('/health', (c) => c.json({ status: 'ok', protocolVersion: PROTOCOL_VERSION }))
const configuration = getConfiguration()
const corsOrigins = options.corsOrigins ?? configuration.corsOrigins
const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins
const corsMiddleware = cors({
origin: corsOriginOption,
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['authorization', 'content-type']
})
app.use('/api/*', corsMiddleware)
app.use('/cli/*', corsMiddleware)
app.route('/cli', createCliRoutes(options.getSyncEngine))
app.route('/api', createAuthRoutes(options.jwtSecret, options.store))
app.route('/api', createBindRoutes(options.jwtSecret, options.store))
app.use('/api/*', createAuthMiddleware(options.jwtSecret))
app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker))
app.route('/api', createSessionsRoutes(options.getSyncEngine))
app.route('/api', createMessagesRoutes(options.getSyncEngine))
app.route('/api', createPermissionsRoutes(options.getSyncEngine))
app.route('/api', createMachinesRoutes(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', createPushRoutes(options.store, options.vapidPublicKey))
app.route('/api', createDevicesRoutes(options.store))
app.route('/api', createVoiceRoutes())
// 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')
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.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()
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('*', 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
jwtSecret: Uint8Array
store: Store
vapidPublicKey: string
socketEngine: SocketEngine
corsOrigins?: string[]
relayMode?: boolean
officialWebUrl?: string
}): Promise<BunServer<WebSocketData>> {
const isCompiled = isBunCompiled()
const embeddedAssetMap = isCompiled ? await loadEmbeddedAssetMap() : null
const app = createWebApp({
getSyncEngine: options.getSyncEngine,
getSseManager: options.getSseManager,
getVisibilityTracker: options.getVisibilityTracker,
jwtSecret: options.jwtSecret,
store: options.store,
vapidPublicKey: options.vapidPublicKey,
corsOrigins: options.corsOrigins,
embeddedAssetMap,
relayMode: options.relayMode,
officialWebUrl: options.officialWebUrl
})
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,
idleTimeout: Math.max(30, socketHandler.idleTimeout),
maxRequestBodySize: Math.max(socketHandler.maxRequestBodySize, 68 * 1024 * 1024),
websocket: {
...originalWsHandler,
open(ws: 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 apiKey = process.env.GEMINI_API_KEY || process.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 apiKey = process.env.DASHSCOPE_API_KEY || process.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
}