diff --git a/.gitignore b/.gitignore index 530320c8..dc3c3d28 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,6 @@ hub/src/web/embeddedAssets.generated.ts hub/src/generated/ # Downloaded tools (fetched at build time) -hub/tools/tunwg/tunwg-* -hub/tools/tunwg/*.exe shared/tools/tunwg/tunwg-* shared/tools/tunwg/*.exe diff --git a/bun.lock b/bun.lock index 4eda3744..fe53220d 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "cross-spawn": "^7.0.6", "fastify": "^5.6.2", "fastify-type-provider-zod": "6.1.0", + "hapi-hub": "workspace:*", "ink": "^6.6.0", "ps-list": "^9.0.0", "react": "^19.2.3", diff --git a/cli/package.json b/cli/package.json index 6f387a1b..c1bddde1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@hapi/protocol": "workspace:*", + "hapi-hub": "workspace:*", "@modelcontextprotocol/sdk": "^1.25.1", "@types/cross-spawn": "^6.0.6", "@types/ps-list": "^6.2.1", diff --git a/cli/scripts/release-all.ts b/cli/scripts/release-all.ts index 748bc96e..a1697f28 100644 --- a/cli/scripts/release-all.ts +++ b/cli/scripts/release-all.ts @@ -16,6 +16,7 @@ import { join } from 'node:path'; const scriptDir = import.meta.dir; const projectRoot = join(scriptDir, '..'); const repoRoot = join(projectRoot, '..'); +const buildInfoPath = join(repoRoot, 'shared', 'src', 'buildInfo.ts'); // 解析参数 const args = process.argv.slice(2); @@ -41,6 +42,22 @@ function run(cmd: string, cwd = projectRoot): void { } } +function updateBuildInfoVersion(nextVersion: string): void { + const content = readFileSync(buildInfoPath, 'utf-8'); + const updated = content.replace( + /export const APP_VERSION = ['"][^'"]+['"]/, + `export const APP_VERSION = '${nextVersion}'` + ); + + if (updated === content) { + throw new Error(`Could not update APP_VERSION in ${buildInfoPath}`); + } + + if (!dryRun) { + writeFileSync(buildInfoPath, updated); + } +} + async function runWithTimeoutRetry(cmd: string, cwd = projectRoot): Promise { const timeoutCmd = `timeout 60s ${cmd}`; while (true) { @@ -93,6 +110,7 @@ async function main(): Promise { if (!dryRun) { writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); } + updateBuildInfoVersion(version); console.log(` ${oldVersion} → ${version}`); // Step 2: Build all platform binaries (with embedded web assets) diff --git a/cli/src/codex/utils/appServerWrappedEvents.ts b/cli/src/codex/utils/appServerWrappedEvents.ts new file mode 100644 index 00000000..b6a59025 --- /dev/null +++ b/cli/src/codex/utils/appServerWrappedEvents.ts @@ -0,0 +1,174 @@ +type WrappedRecord = Record; + +type WrappedNotification = { + method: string; + params: WrappedRecord; +}; + +function asRecord(value: unknown): WrappedRecord | null { + return value && typeof value === 'object' ? value as WrappedRecord : null; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function scopeFrom(value: WrappedRecord): WrappedRecord { + const thread = asRecord(value.thread); + const turn = asRecord(value.turn); + const threadId = asString(value.thread_id ?? value.threadId ?? thread?.id ?? thread?.thread_id ?? thread?.threadId); + const turnId = asString(value.turn_id ?? value.turnId ?? turn?.id ?? turn?.turn_id ?? turn?.turnId); + + return { + ...(threadId ? { thread_id: threadId } : {}), + ...(turnId ? { turn_id: turnId } : {}) + }; +} + +function itemIdFrom(value: WrappedRecord, item: WrappedRecord | null = null): string | null { + return asString(value.item_id ?? value.itemId ?? value.id ?? item?.id ?? item?.item_id ?? item?.itemId); +} + +export function isWrappedTerminalEventType(type: string): boolean { + return type === 'task_started' + || type === 'task_complete' + || type === 'turn_aborted' + || type === 'task_failed'; +} + +export function buildWrappedTerminalEvent(msg: WrappedRecord, inheritedScope: WrappedRecord): WrappedRecord | null { + const type = asString(msg.type); + if (!type || !isWrappedTerminalEventType(type)) { + return null; + } + + const scope = { ...inheritedScope, ...scopeFrom(msg) }; + const turnId = asString(msg.turn_id ?? msg.turnId ?? scope.turn_id); + if ((type === 'task_complete' || type === 'turn_aborted' || type === 'task_failed') && !turnId) { + return null; + } + + const threadId = asString(msg.thread_id ?? msg.threadId ?? scope.thread_id); + const errorRecord = asRecord(msg.error); + const error = asString(msg.error ?? msg.message ?? errorRecord?.message); + + return { + type, + ...(threadId ? { thread_id: threadId } : {}), + ...(turnId ? { turn_id: turnId } : {}), + ...(type === 'task_failed' && error ? { error } : {}) + }; +} + +export function buildWrappedTextDeltaNotification(msg: WrappedRecord, inheritedScope: WrappedRecord): WrappedNotification | null { + const type = asString(msg.type); + const scope = { ...inheritedScope, ...scopeFrom(msg) }; + + if (type === 'agent_message_delta' || type === 'agent_message_content_delta') { + const itemId = itemIdFrom(msg); + const delta = asString(msg.delta ?? msg.text ?? msg.message); + if (!itemId || !delta) { + return null; + } + return { + method: 'item/agentMessage/delta', + params: { itemId, delta, ...scope } + }; + } + + if (type === 'exec_command_output_delta') { + const itemId = asString(msg.call_id ?? msg.callId) ?? itemIdFrom(msg); + const delta = asString(msg.delta ?? msg.output ?? msg.stdout ?? msg.text); + if (!itemId || !delta) { + return null; + } + return { + method: 'item/commandExecution/outputDelta', + params: { itemId, delta, ...scope } + }; + } + + return null; +} + +export function buildWrappedReasoningSectionBreakNotification(msg: WrappedRecord, inheritedScope: WrappedRecord): WrappedNotification | null { + if (msg.type !== 'agent_reasoning_section_break') { + return null; + } + + const itemId = itemIdFrom(msg); + if (!itemId) { + return null; + } + + const summaryIndex = asNumber(msg.summary_index ?? msg.summaryIndex); + return { + method: 'item/reasoning/summaryPartAdded', + params: { + itemId, + ...inheritedScope, + ...scopeFrom(msg), + ...(summaryIndex !== null ? { summaryIndex } : {}) + } + }; +} + +export function buildWrappedItemNotification(msg: WrappedRecord, inheritedScope: WrappedRecord): WrappedNotification | null { + const type = asString(msg.type); + if (type !== 'item_started' && type !== 'item_completed') { + return null; + } + + const item = asRecord(msg.item) ?? {}; + const itemThread = asRecord(item.thread); + const itemTurn = asRecord(item.turn); + const scope = { ...inheritedScope, ...scopeFrom(msg) }; + const threadId = asString(msg.thread_id ?? msg.threadId ?? scope.thread_id ?? item.thread_id ?? item.threadId ?? itemThread?.id); + const turnId = asString(msg.turn_id ?? msg.turnId ?? scope.turn_id ?? item.turn_id ?? item.turnId ?? itemTurn?.id); + + return { + method: type === 'item_started' ? 'item/started' : 'item/completed', + params: { + ...scope, + item, + ...(itemIdFrom(msg, item) ? { itemId: itemIdFrom(msg, item) } : {}), + ...(threadId ? { threadId } : {}), + ...(turnId ? { turnId } : {}) + } + }; +} + +export function isIgnoredWrappedCodexEventType(type: string): boolean { + return type === 'agent_message' + || type === 'agent_reasoning_delta' + || type === 'agent_reasoning' + || type === 'mcp_startup_update' + || type === 'mcp_startup_complete' + || type === 'skills_update_available' + || type === 'stream_error' + || type === 'warning' + || type === 'terminal_interaction' + || type === 'user_message'; +} + +export function buildWrappedErrorEvent(msg: WrappedRecord): WrappedRecord | null { + if (msg.type !== undefined && msg.type !== 'error') { + return null; + } + + const errorRecord = asRecord(msg.error); + const willRetry = msg.will_retry === true + || msg.willRetry === true + || errorRecord?.will_retry === true + || errorRecord?.willRetry === true; + if (willRetry) { + return null; + } + + const error = asString(msg.message ?? msg.reason ?? errorRecord?.message); + return error ? { type: 'task_failed', error } : null; +} diff --git a/cli/src/commands/hub.ts b/cli/src/commands/hub.ts index bcebef4b..b702cc86 100644 --- a/cli/src/commands/hub.ts +++ b/cli/src/commands/hub.ts @@ -28,12 +28,14 @@ export const hubCommand: CommandDefinition = { const { host, port } = parseHubArgs(context.commandArgs) if (host) { - process.env.WEBAPP_HOST = host + process.env.HAPI_LISTEN_HOST = host } if (port) { - process.env.WEBAPP_PORT = port + process.env.HAPI_LISTEN_PORT = port } - await import('../../../hub/src/index') + const { startHub } = await import('hapi-hub/startHub') + await startHub({ args: context.commandArgs }) + await new Promise(() => {}) } catch (error) { console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') if (process.env.DEBUG) { diff --git a/cli/src/runtime/assets.ts b/cli/src/runtime/assets.ts index 5f22b8ef..32262570 100644 --- a/cli/src/runtime/assets.ts +++ b/cli/src/runtime/assets.ts @@ -172,8 +172,8 @@ export function getTunwgPath(): string { return join(runtimePath(), 'tools', 'tunwg', tunwgBinary); } - // Development mode: use downloaded binary from hub/tools/tunwg + // Development mode: use downloaded binary from shared/tools/tunwg const platformDir = getPlatformDir(); const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}`; - return join(__dirname, '..', '..', '..', 'hub', 'tools', 'tunwg', devBinaryName); + return join(__dirname, '..', '..', '..', 'shared', 'tools', 'tunwg', devBinaryName); } diff --git a/cli/src/runtime/embeddedAssets.bun.ts b/cli/src/runtime/embeddedAssets.bun.ts index e5d8572b..72a9638d 100644 --- a/cli/src/runtime/embeddedAssets.bun.ts +++ b/cli/src/runtime/embeddedAssets.bun.ts @@ -4,7 +4,7 @@ import difftasticArchiveLicense from '../../tools/archives/difftastic-LICENSE' a import ripgrepArchiveLicense from '../../tools/archives/ripgrep-LICENSE' assert { type: 'file' }; import difftasticLicense from '../../tools/licenses/difftastic-LICENSE' assert { type: 'file' }; import ripgrepLicense from '../../tools/licenses/ripgrep-LICENSE' assert { type: 'file' }; -import tunwgLicense from '../../../hub/tools/tunwg/LICENSE' assert { type: 'file' }; +import tunwgLicense from '../../../shared/tools/tunwg/LICENSE' assert { type: 'file' }; export interface EmbeddedAsset { relativePath: string; @@ -35,7 +35,7 @@ async function selectEmbeddedAssets(): Promise { ] = await Promise.all([ import('../../tools/archives/difftastic-arm64-darwin.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/ripgrep-arm64-darwin.tar.gz', { assert: { type: 'file' } }), - import('../../../hub/tools/tunwg/tunwg-arm64-darwin', { assert: { type: 'file' } }) + import('../../../shared/tools/tunwg/tunwg-arm64-darwin', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, @@ -53,7 +53,7 @@ async function selectEmbeddedAssets(): Promise { ] = await Promise.all([ import('../../tools/archives/difftastic-x64-darwin.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/ripgrep-x64-darwin.tar.gz', { assert: { type: 'file' } }), - import('../../../hub/tools/tunwg/tunwg-x64-darwin', { assert: { type: 'file' } }) + import('../../../shared/tools/tunwg/tunwg-x64-darwin', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, @@ -71,7 +71,7 @@ async function selectEmbeddedAssets(): Promise { ] = await Promise.all([ import('../../tools/archives/difftastic-arm64-linux.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/ripgrep-arm64-linux.tar.gz', { assert: { type: 'file' } }), - import('../../../hub/tools/tunwg/tunwg-arm64-linux', { assert: { type: 'file' } }) + import('../../../shared/tools/tunwg/tunwg-arm64-linux', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, @@ -89,7 +89,7 @@ async function selectEmbeddedAssets(): Promise { ] = await Promise.all([ import('../../tools/archives/difftastic-x64-linux.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/ripgrep-x64-linux.tar.gz', { assert: { type: 'file' } }), - import('../../../hub/tools/tunwg/tunwg-x64-linux', { assert: { type: 'file' } }) + import('../../../shared/tools/tunwg/tunwg-x64-linux', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, @@ -107,7 +107,7 @@ async function selectEmbeddedAssets(): Promise { ] = await Promise.all([ import('../../tools/archives/difftastic-x64-win32.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/ripgrep-x64-win32.tar.gz', { assert: { type: 'file' } }), - import('../../../hub/tools/tunwg/tunwg-x64-win32.exe', { assert: { type: 'file' } }) + import('../../../shared/tools/tunwg/tunwg-x64-win32.exe', { assert: { type: 'file' } }) ]); return [ ...COMMON_ASSETS, diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 2b42bacf..5b2abe9b 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -6,7 +6,7 @@ "es2022" ], "jsx": "react", - "rootDir": "..", + "rootDir": "src", "experimentalDecorators": true, "outDir": "dist", "noEmit": true, @@ -26,8 +26,6 @@ "include": [ "src/**/*.ts", "src/**/*.tsx", - "src/**/*.d.ts", - "../hub/src/**/*.ts", - "../hub/src/**/*.d.ts" + "src/**/*.d.ts" ] } diff --git a/hub/package.json b/hub/package.json index 900d6f1d..046402e2 100644 --- a/hub/package.json +++ b/hub/package.json @@ -6,6 +6,12 @@ "author": "weishu", "license": "AGPL-3.0-only", "type": "module", + "exports": { + "./startHub": { + "types": "./src/startHub.d.ts", + "default": "./src/startHub.ts" + } + }, "scripts": { "start": "bun run src/index.ts", "dev": "bun --watch run src/index.ts", diff --git a/hub/scripts/download-tunwg.ts b/hub/scripts/download-tunwg.ts index 4991986e..655ebeb9 100644 --- a/hub/scripts/download-tunwg.ts +++ b/hub/scripts/download-tunwg.ts @@ -2,7 +2,7 @@ * Download tunwg binaries for all platforms * * Downloads pre-built tunwg binaries from GitHub releases. - * Output directory: hub/tools/tunwg/ + * Output directory: shared/tools/tunwg/ */ import { existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'; @@ -46,7 +46,7 @@ async function main(): Promise { } else { scriptDir = dirname(new URL(import.meta.url).pathname); } - const toolsDir = join(scriptDir, '..', 'tools', 'tunwg'); + const toolsDir = join(scriptDir, '..', '..', 'shared', 'tools', 'tunwg'); console.log('Downloading tunwg binaries...\n'); diff --git a/hub/src/index.ts b/hub/src/index.ts index f39e9239..691afcb5 100644 --- a/hub/src/index.ts +++ b/hub/src/index.ts @@ -1,320 +1,11 @@ -/** - * HAPI Hub - Main Entry Point - * - * Provides: - * - Web app + HTTP API - * - Socket.IO for CLI connections - * - SSE updates for the web UI - * - Optional Telegram bot for notifications and Mini App entrypoint - */ - -import { createConfiguration, type ConfigSource } from './configuration' -import { Store } from './store' -import { SyncEngine, type SyncEvent } from './sync/syncEngine' -import { NotificationHub } from './notifications/notificationHub' -import type { NotificationChannel } from './notifications/notificationTypes' -import { HappyBot } from './telegram/bot' -import { startWebServer } from './web/server' -import { getOrCreateJwtSecret } from './config/jwtSecret' -import { createSocketServer } from './socket/server' -import { SSEManager } from './sse/sseManager' -import { getOrCreateVapidKeys } from './config/vapidKeys' -import { PushService } from './push/pushService' -import { PushNotificationChannel } from './push/pushNotificationChannel' -import { VisibilityTracker } from './visibility/visibilityTracker' -import { TunnelManager } from './tunnel' -import { waitForTunnelTlsReady } from './tunnel/tlsGate' -import { ServerChanChannel } from './serverchan/channel' -import QRCode from 'qrcode' -import type { Server as BunServer } from 'bun' -import type { WebSocketData } from '@socket.io/bun-engine' - -/** Format config source for logging */ -function formatSource(source: ConfigSource | 'generated'): string { - switch (source) { - case 'env': - return 'environment' - case 'file': - return 'settings.json' - case 'default': - return 'default' - case 'generated': - return 'generated' - } -} - -type RelayFlagSource = 'default' | '--relay' | '--no-relay' - -function resolveRelayFlag(args: string[]): { enabled: boolean; source: RelayFlagSource } { - let enabled = false - let source: RelayFlagSource = 'default' - - for (const arg of args) { - if (arg === '--relay') { - enabled = true - source = '--relay' - } else if (arg === '--no-relay') { - enabled = false - source = '--no-relay' - } - } - - return { enabled, source } -} - -function normalizeOrigin(value: string): string { - const trimmed = value.trim() - if (!trimmed) { - return '' - } - try { - return new URL(trimmed).origin - } catch { - return trimmed - } -} - -function normalizeOrigins(origins: string[]): string[] { - const normalized = origins - .map(normalizeOrigin) - .filter(Boolean) - if (normalized.includes('*')) { - return ['*'] - } - return Array.from(new Set(normalized)) -} - -function mergeCorsOrigins(base: string[], extra: string[]): string[] { - if (base.includes('*') || extra.includes('*')) { - return ['*'] - } - const merged = new Set() - for (const origin of base) { - merged.add(origin) - } - for (const origin of extra) { - merged.add(origin) - } - return Array.from(merged) -} - -let syncEngine: SyncEngine | null = null -let happyBot: HappyBot | null = null -let webServer: BunServer | null = null -let sseManager: SSEManager | null = null -let visibilityTracker: VisibilityTracker | null = null -let notificationHub: NotificationHub | null = null -let tunnelManager: TunnelManager | null = null +import { startHub } from './startHub' async function main() { - console.log('HAPI Hub starting...') + const hub = await startHub() - // Load configuration (async - loads from env/file with persistence) - const relayApiDomain = process.env.HAPI_RELAY_API || 'relay.hapi.run' - const relayFlag = resolveRelayFlag(process.argv) - const officialWebUrl = process.env.HAPI_OFFICIAL_WEB_URL || 'https://app.hapi.run' - const config = await createConfiguration() - const baseCorsOrigins = normalizeOrigins(config.corsOrigins) - const relayCorsOrigin = normalizeOrigin(officialWebUrl) - const corsOrigins = relayFlag.enabled - ? mergeCorsOrigins(baseCorsOrigins, relayCorsOrigin ? [relayCorsOrigin] : []) - : baseCorsOrigins - - // Display CLI API token information - if (config.cliApiTokenIsNew) { - console.log('') - console.log('='.repeat(70)) - console.log(' NEW CLI_API_TOKEN GENERATED') - console.log('='.repeat(70)) - console.log('') - console.log(` Token: ${config.cliApiToken}`) - console.log('') - console.log(` Saved to: ${config.settingsFile}`) - console.log('') - console.log('='.repeat(70)) - console.log('') - } else { - console.log(`[Hub] CLI_API_TOKEN: loaded from ${formatSource(config.sources.cliApiToken)}`) - } - - // Display other configuration sources - console.log(`[Hub] HAPI_LISTEN_HOST: ${config.listenHost} (${formatSource(config.sources.listenHost)})`) - console.log(`[Hub] HAPI_LISTEN_PORT: ${config.listenPort} (${formatSource(config.sources.listenPort)})`) - console.log(`[Hub] HAPI_PUBLIC_URL: ${config.publicUrl} (${formatSource(config.sources.publicUrl)})`) - - if (!config.telegramEnabled) { - console.log('[Hub] Telegram: disabled (no TELEGRAM_BOT_TOKEN)') - } else { - const tokenSource = formatSource(config.sources.telegramBotToken) - console.log(`[Hub] Telegram: enabled (${tokenSource})`) - const notificationSource = formatSource(config.sources.telegramNotification) - console.log(`[Hub] Telegram notifications: ${config.telegramNotification ? 'enabled' : 'disabled'} (${notificationSource})`) - } - if (config.serverChanSendKey) { - const source = formatSource(config.sources.serverChanSendKey) - const notificationSource = formatSource(config.sources.serverChanNotification) - console.log(`[Hub] ServerChan: enabled (${source})`) - console.log(`[Hub] ServerChan notifications: ${config.serverChanNotification ? 'enabled' : 'disabled'} (${notificationSource})`) - } else { - console.log('[Hub] ServerChan: disabled (no SERVERCHAN_SENDKEY)') - } - - // Display tunnel status - if (relayFlag.enabled) { - console.log(`[Hub] Tunnel: enabled (${relayFlag.source}), API: ${relayApiDomain}`) - } else { - console.log(`[Hub] Tunnel: disabled (${relayFlag.source})`) - } - - const store = new Store(config.dbPath) - const jwtSecret = await getOrCreateJwtSecret() - const vapidKeys = await getOrCreateVapidKeys(config.dataDir) - const vapidSubject = process.env.VAPID_SUBJECT ?? 'mailto:admin@hapi.run' - const pushService = new PushService(vapidKeys, vapidSubject, store) - - visibilityTracker = new VisibilityTracker() - sseManager = new SSEManager(30_000, visibilityTracker) - - const socketServer = createSocketServer({ - store, - jwtSecret, - corsOrigins, - getSession: (sessionId) => { - if (syncEngine) { - return syncEngine.getSession(sessionId) ?? null - } - return store.sessions.getSession(sessionId) - }, - onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event), - onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload), - onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload), - onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload), - onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta), - onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt), - onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now) - }) - - syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) - - const notificationChannels: NotificationChannel[] = [ - new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.publicUrl) - ] - - if (config.serverChanSendKey && config.serverChanNotification) { - notificationChannels.push(new ServerChanChannel(config.serverChanSendKey, config.publicUrl)) - } - - // Initialize Telegram bot (optional) - if (config.telegramEnabled && config.telegramBotToken) { - happyBot = new HappyBot({ - syncEngine, - botToken: config.telegramBotToken, - publicUrl: config.publicUrl, - store - }) - // Only add to notification channels if notifications are enabled - if (config.telegramNotification) { - notificationChannels.push(happyBot) - } - } - - notificationHub = new NotificationHub(syncEngine, notificationChannels) - - // Start HTTP service first (before tunnel, so tunnel has something to forward to) - webServer = await startWebServer({ - getSyncEngine: () => syncEngine, - getSseManager: () => sseManager, - getVisibilityTracker: () => visibilityTracker, - jwtSecret, - store, - vapidPublicKey: vapidKeys.publicKey, - socketEngine: socketServer.engine, - corsOrigins, - relayMode: relayFlag.enabled, - officialWebUrl - }) - - // Start the bot if configured - if (happyBot) { - await happyBot.start() - } - - console.log('') - console.log('[Web] Hub listening on :' + config.listenPort) - console.log('[Web] Local: http://localhost:' + config.listenPort) - - // Initialize tunnel AFTER web service is ready - let tunnelUrl: string | null = null - if (relayFlag.enabled) { - tunnelManager = new TunnelManager({ - localPort: config.listenPort, - enabled: true, - apiDomain: relayApiDomain, - authKey: process.env.HAPI_RELAY_AUTH || null, - useRelay: process.env.HAPI_RELAY_FORCE_TCP === 'true' || process.env.HAPI_RELAY_FORCE_TCP === '1' - }) - - try { - tunnelUrl = await tunnelManager.start() - } catch (error) { - console.error('[Tunnel] Failed to start:', error instanceof Error ? error.message : error) - console.log('[Tunnel] Hub continuing without tunnel. Restart without --relay to disable.') - } - } - - if (tunnelUrl && tunnelManager) { - const manager = tunnelManager - const announceTunnelAccess = async () => { - const tlsReady = await waitForTunnelTlsReady(tunnelUrl, manager) - if (!tlsReady) { - console.log('[Tunnel] Tunnel stopped before TLS was ready.') - return - } - - console.log('[Web] Public: ' + tunnelUrl) - - // Generate direct access link with hub and token - const params = new URLSearchParams({ - hub: tunnelUrl, - token: config.cliApiToken - }) - const directAccessUrl = `${officialWebUrl}/?${params.toString()}` - - console.log('') - console.log('Open in browser:') - console.log(` ${directAccessUrl}`) - console.log('') - console.log('or scan the QR code to open:') - - // Display QR code for easy mobile access - try { - const qrString = await QRCode.toString(directAccessUrl, { - type: 'terminal', - small: true, - margin: 1, - errorCorrectionLevel: 'L' - }) - console.log('') - console.log(qrString) - } catch { - // QR code generation failure should not affect main flow - } - } - - void announceTunnelAccess() - } - console.log('') - console.log('HAPI Hub is ready!') - - // Handle shutdown const shutdown = async () => { console.log('\nShutting down...') - await tunnelManager?.stop() - await happyBot?.stop() - notificationHub?.stop() - syncEngine?.stop() - sseManager?.stop() - webServer?.stop() + await hub.stop() process.exit(0) } diff --git a/hub/src/startHub.d.ts b/hub/src/startHub.d.ts new file mode 100644 index 00000000..e03f4dc1 --- /dev/null +++ b/hub/src/startHub.d.ts @@ -0,0 +1,9 @@ +export interface HubInstance { + stop(): Promise +} + +export interface StartHubOptions { + args?: string[] +} + +export function startHub(options?: StartHubOptions): Promise diff --git a/hub/src/startHub.ts b/hub/src/startHub.ts new file mode 100644 index 00000000..d645632b --- /dev/null +++ b/hub/src/startHub.ts @@ -0,0 +1,317 @@ +import { createConfiguration, type ConfigSource } from './configuration' +import { Store } from './store' +import { SyncEngine, type SyncEvent } from './sync/syncEngine' +import { NotificationHub } from './notifications/notificationHub' +import type { NotificationChannel } from './notifications/notificationTypes' +import { HappyBot } from './telegram/bot' +import { startWebServer } from './web/server' +import { getOrCreateJwtSecret } from './config/jwtSecret' +import { createSocketServer } from './socket/server' +import { SSEManager } from './sse/sseManager' +import { getOrCreateVapidKeys } from './config/vapidKeys' +import { PushService } from './push/pushService' +import { PushNotificationChannel } from './push/pushNotificationChannel' +import { VisibilityTracker } from './visibility/visibilityTracker' +import { TunnelManager } from './tunnel' +import { waitForTunnelTlsReady } from './tunnel/tlsGate' +import { ServerChanChannel } from './serverchan/channel' +import QRCode from 'qrcode' +import type { Server as BunServer } from 'bun' +import type { WebSocketData } from '@socket.io/bun-engine' + +/** Format config source for logging */ +function formatSource(source: ConfigSource | 'generated'): string { + switch (source) { + case 'env': + return 'environment' + case 'file': + return 'settings.json' + case 'default': + return 'default' + case 'generated': + return 'generated' + } +} + +type RelayFlagSource = 'default' | '--relay' | '--no-relay' + +function resolveRelayFlag(args: string[]): { enabled: boolean; source: RelayFlagSource } { + let enabled = false + let source: RelayFlagSource = 'default' + + for (const arg of args) { + if (arg === '--relay') { + enabled = true + source = '--relay' + } else if (arg === '--no-relay') { + enabled = false + source = '--no-relay' + } + } + + return { enabled, source } +} + +function normalizeOrigin(value: string): string { + const trimmed = value.trim() + if (!trimmed) { + return '' + } + try { + return new URL(trimmed).origin + } catch { + return trimmed + } +} + +function normalizeOrigins(origins: string[]): string[] { + const normalized = origins + .map(normalizeOrigin) + .filter(Boolean) + if (normalized.includes('*')) { + return ['*'] + } + return Array.from(new Set(normalized)) +} + +function mergeCorsOrigins(base: string[], extra: string[]): string[] { + if (base.includes('*') || extra.includes('*')) { + return ['*'] + } + const merged = new Set() + for (const origin of base) { + merged.add(origin) + } + for (const origin of extra) { + merged.add(origin) + } + return Array.from(merged) +} + +export interface HubInstance { + stop(): Promise +} + +export interface StartHubOptions { + args?: string[] +} + +export async function startHub(options: StartHubOptions = {}): Promise { + console.log('HAPI Hub starting...') + + let syncEngine: SyncEngine | null = null + let happyBot: HappyBot | null = null + let webServer: BunServer | null = null + let sseManager: SSEManager | null = null + let visibilityTracker: VisibilityTracker | null = null + let notificationHub: NotificationHub | null = null + let tunnelManager: TunnelManager | null = null + + // Load configuration (async - loads from env/file with persistence) + const relayApiDomain = process.env.HAPI_RELAY_API || 'relay.hapi.run' + const relayFlag = resolveRelayFlag(options.args ?? process.argv) + const officialWebUrl = process.env.HAPI_OFFICIAL_WEB_URL || 'https://app.hapi.run' + const config = await createConfiguration() + const baseCorsOrigins = normalizeOrigins(config.corsOrigins) + const relayCorsOrigin = normalizeOrigin(officialWebUrl) + const corsOrigins = relayFlag.enabled + ? mergeCorsOrigins(baseCorsOrigins, relayCorsOrigin ? [relayCorsOrigin] : []) + : baseCorsOrigins + + // Display CLI API token information + if (config.cliApiTokenIsNew) { + console.log('') + console.log('='.repeat(70)) + console.log(' NEW CLI_API_TOKEN GENERATED') + console.log('='.repeat(70)) + console.log('') + console.log(` Token: ${config.cliApiToken}`) + console.log('') + console.log(` Saved to: ${config.settingsFile}`) + console.log('') + console.log('='.repeat(70)) + console.log('') + } else { + console.log(`[Hub] CLI_API_TOKEN: loaded from ${formatSource(config.sources.cliApiToken)}`) + } + + // Display other configuration sources + console.log(`[Hub] HAPI_LISTEN_HOST: ${config.listenHost} (${formatSource(config.sources.listenHost)})`) + console.log(`[Hub] HAPI_LISTEN_PORT: ${config.listenPort} (${formatSource(config.sources.listenPort)})`) + console.log(`[Hub] HAPI_PUBLIC_URL: ${config.publicUrl} (${formatSource(config.sources.publicUrl)})`) + + if (!config.telegramEnabled) { + console.log('[Hub] Telegram: disabled (no TELEGRAM_BOT_TOKEN)') + } else { + const tokenSource = formatSource(config.sources.telegramBotToken) + console.log(`[Hub] Telegram: enabled (${tokenSource})`) + const notificationSource = formatSource(config.sources.telegramNotification) + console.log(`[Hub] Telegram notifications: ${config.telegramNotification ? 'enabled' : 'disabled'} (${notificationSource})`) + } + if (config.serverChanSendKey) { + const source = formatSource(config.sources.serverChanSendKey) + const notificationSource = formatSource(config.sources.serverChanNotification) + console.log(`[Hub] ServerChan: enabled (${source})`) + console.log(`[Hub] ServerChan notifications: ${config.serverChanNotification ? 'enabled' : 'disabled'} (${notificationSource})`) + } else { + console.log('[Hub] ServerChan: disabled (no SERVERCHAN_SENDKEY)') + } + + // Display tunnel status + if (relayFlag.enabled) { + console.log(`[Hub] Tunnel: enabled (${relayFlag.source}), API: ${relayApiDomain}`) + } else { + console.log(`[Hub] Tunnel: disabled (${relayFlag.source})`) + } + + const store = new Store(config.dbPath) + const jwtSecret = await getOrCreateJwtSecret() + const vapidKeys = await getOrCreateVapidKeys(config.dataDir) + const vapidSubject = process.env.VAPID_SUBJECT ?? 'mailto:admin@hapi.run' + const pushService = new PushService(vapidKeys, vapidSubject, store) + + visibilityTracker = new VisibilityTracker() + sseManager = new SSEManager(30_000, visibilityTracker) + + const socketServer = createSocketServer({ + store, + jwtSecret, + corsOrigins, + getSession: (sessionId) => { + if (syncEngine) { + return syncEngine.getSession(sessionId) ?? null + } + return store.sessions.getSession(sessionId) + }, + onWebappEvent: (event: SyncEvent) => syncEngine?.handleRealtimeEvent(event), + onSessionAlive: (payload) => syncEngine?.handleSessionAlive(payload), + onSessionEnd: (payload) => syncEngine?.handleSessionEnd(payload), + onMachineAlive: (payload) => syncEngine?.handleMachineAlive(payload), + onBackgroundTaskDelta: (sessionId, delta) => syncEngine?.handleBackgroundTaskDelta(sessionId, delta), + onSessionActivity: (sessionId, updatedAt) => syncEngine?.recordSessionActivity(sessionId, updatedAt), + onSweepImmediateQueued: (sessionId, now) => syncEngine?.sweepImmediateQueuedOnSessionEnd(sessionId, now) + }) + + syncEngine = new SyncEngine(store, socketServer.io, socketServer.rpcRegistry, sseManager) + + const notificationChannels: NotificationChannel[] = [ + new PushNotificationChannel(pushService, sseManager, visibilityTracker, config.publicUrl) + ] + + if (config.serverChanSendKey && config.serverChanNotification) { + notificationChannels.push(new ServerChanChannel(config.serverChanSendKey, config.publicUrl)) + } + + // Initialize Telegram bot (optional) + if (config.telegramEnabled && config.telegramBotToken) { + happyBot = new HappyBot({ + syncEngine, + botToken: config.telegramBotToken, + publicUrl: config.publicUrl, + store + }) + // Only add to notification channels if notifications are enabled + if (config.telegramNotification) { + notificationChannels.push(happyBot) + } + } + + notificationHub = new NotificationHub(syncEngine, notificationChannels) + + // Start HTTP service first (before tunnel, so tunnel has something to forward to) + webServer = await startWebServer({ + getSyncEngine: () => syncEngine, + getSseManager: () => sseManager, + getVisibilityTracker: () => visibilityTracker, + jwtSecret, + store, + vapidPublicKey: vapidKeys.publicKey, + socketEngine: socketServer.engine, + corsOrigins, + relayMode: relayFlag.enabled, + officialWebUrl + }) + + // Start the bot if configured + if (happyBot) { + await happyBot.start() + } + + console.log('') + console.log('[Web] Hub listening on :' + config.listenPort) + console.log('[Web] Local: http://localhost:' + config.listenPort) + + // Initialize tunnel AFTER web service is ready + let tunnelUrl: string | null = null + if (relayFlag.enabled) { + tunnelManager = new TunnelManager({ + localPort: config.listenPort, + enabled: true, + apiDomain: relayApiDomain, + authKey: process.env.HAPI_RELAY_AUTH || null, + useRelay: process.env.HAPI_RELAY_FORCE_TCP === 'true' || process.env.HAPI_RELAY_FORCE_TCP === '1' + }) + + try { + tunnelUrl = await tunnelManager.start() + } catch (error) { + console.error('[Tunnel] Failed to start:', error instanceof Error ? error.message : error) + console.log('[Tunnel] Hub continuing without tunnel. Restart without --relay to disable.') + } + } + + if (tunnelUrl && tunnelManager) { + const manager = tunnelManager + const announceTunnelAccess = async () => { + const tlsReady = await waitForTunnelTlsReady(tunnelUrl, manager) + if (!tlsReady) { + console.log('[Tunnel] Tunnel stopped before TLS was ready.') + return + } + + console.log('[Web] Public: ' + tunnelUrl) + + // Generate direct access link with hub and token + const params = new URLSearchParams({ + hub: tunnelUrl, + token: config.cliApiToken + }) + const directAccessUrl = `${officialWebUrl}/?${params.toString()}` + + console.log('') + console.log('Open in browser:') + console.log(` ${directAccessUrl}`) + console.log('') + console.log('or scan the QR code to open:') + + // Display QR code for easy mobile access + try { + const qrString = await QRCode.toString(directAccessUrl, { + type: 'terminal', + small: true, + margin: 1, + errorCorrectionLevel: 'L' + }) + console.log('') + console.log(qrString) + } catch { + // QR code generation failure should not affect main flow + } + } + + void announceTunnelAccess() + } + console.log('') + console.log('HAPI Hub is ready!') + + return { + stop: async () => { + await tunnelManager?.stop() + await happyBot?.stop() + notificationHub?.stop() + syncEngine?.stop() + sseManager?.stop() + webServer?.stop() + } + } +} diff --git a/hub/src/tunnel/tunnelManager.ts b/hub/src/tunnel/tunnelManager.ts index 47a1ea87..b95d6e99 100644 --- a/hub/src/tunnel/tunnelManager.ts +++ b/hub/src/tunnel/tunnelManager.ts @@ -13,6 +13,7 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import { platform, arch, homedir } from 'node:os' import { isBunCompiled } from '../utils/bunCompiled' +import { APP_VERSION } from '@hapi/protocol' function getHapiHome(): string { return process.env.HAPI_HOME @@ -43,15 +44,14 @@ function getTunwgPath(): string { if (isBunCompiled()) { const hapiHome = getHapiHome() - const packageJson = require('../../../cli/package.json') - const runtimePath = join(hapiHome, 'runtime', packageJson.version) + const runtimePath = join(hapiHome, 'runtime', APP_VERSION) return join(runtimePath, 'tools', 'tunwg', tunwgBinary) } - // Development mode: use downloaded binary from hub/tools/tunwg + // Development mode: use downloaded binary from shared/tools/tunwg const platformDir = getPlatformDir() const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}` - return join(__dirname, '..', '..', 'tools', 'tunwg', devBinaryName) + return join(__dirname, '..', '..', '..', 'shared', 'tools', 'tunwg', devBinaryName) } export interface TunnelConfig { diff --git a/hub/tools/.gitignore b/hub/tools/.gitignore deleted file mode 100644 index a59d59dc..00000000 --- a/hub/tools/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -tunwg/* -!tunwg/ -!tunwg/LICENSE diff --git a/package.json b/package.json index 2e42c216..3bf9766b 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "build:site": "bun run --cwd website build && bun run --cwd docs docs:build && cp -r docs/.vitepress/dist website/dist/public/docs", "dev": "concurrently \"bun run dev:hub\" \"bun run dev:web\" --kill-others-on-exit", - "build": "bun run build:cli && bun run build:hub && bun run build:web", - "build:cli": "cd cli && bun run build", + "build": "bun run build:cli && bun run build:web && (cd hub && bun run generate:embedded-web-assets) && bun run build:hub", + "build:cli": "bun run typecheck:cli", "build:single-exe": "bun run download:tunwg && bun run build:web && (cd hub && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone)", "build:single-exe:all": "bun run download:tunwg && bun run build:web && (cd hub && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone:all)", "download:tunwg": "bun run hub/scripts/download-tunwg.ts", diff --git a/shared/package.json b/shared/package.json index ca7ba820..85fa2e53 100644 --- a/shared/package.json +++ b/shared/package.json @@ -8,6 +8,7 @@ "exports": { ".": "./src/index.ts", "./messages": "./src/messages.ts", + "./buildInfo": "./src/buildInfo.ts", "./modes": "./src/modes.ts", "./schemas": "./src/schemas.ts", "./types": "./src/types.ts", diff --git a/shared/src/buildInfo.ts b/shared/src/buildInfo.ts new file mode 100644 index 00000000..77310a3c --- /dev/null +++ b/shared/src/buildInfo.ts @@ -0,0 +1 @@ +export const APP_VERSION = '0.18.1' diff --git a/shared/src/index.ts b/shared/src/index.ts index f8072d25..a21a600a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './messages' +export * from './buildInfo' export * from './flavors' export * from './models' export * from './modes' diff --git a/hub/tools/tunwg/LICENSE b/shared/tools/tunwg/LICENSE similarity index 100% rename from hub/tools/tunwg/LICENSE rename to shared/tools/tunwg/LICENSE diff --git a/web/vite.config.ts b/web/vite.config.ts index 631ac981..26a6adbd 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,9 +2,8 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { resolve } from 'node:path' -import { createRequire } from 'node:module' +import { APP_VERSION } from '@hapi/protocol/buildInfo' -const require = createRequire(import.meta.url) const base = process.env.VITE_BASE_URL || '/' const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' @@ -34,7 +33,7 @@ function getVendorChunkName(id: string): string | undefined { export default defineConfig({ define: { - __APP_VERSION__: JSON.stringify(require('../cli/package.json').version), + __APP_VERSION__: JSON.stringify(APP_VERSION), }, server: { host: true,