mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* 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>
504 lines
21 KiB
TypeScript
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
|
|
}
|