Clean up cross-package build coupling

This commit is contained in:
weishu
2026-05-20 19:29:43 +08:00
parent 6759cf4657
commit 83795c0630
22 changed files with 557 additions and 343 deletions
-2
View File
@@ -10,8 +10,6 @@ hub/src/web/embeddedAssets.generated.ts
hub/src/generated/ hub/src/generated/
# Downloaded tools (fetched at build time) # Downloaded tools (fetched at build time)
hub/tools/tunwg/tunwg-*
hub/tools/tunwg/*.exe
shared/tools/tunwg/tunwg-* shared/tools/tunwg/tunwg-*
shared/tools/tunwg/*.exe shared/tools/tunwg/*.exe
+1
View File
@@ -28,6 +28,7 @@
"cross-spawn": "^7.0.6", "cross-spawn": "^7.0.6",
"fastify": "^5.6.2", "fastify": "^5.6.2",
"fastify-type-provider-zod": "6.1.0", "fastify-type-provider-zod": "6.1.0",
"hapi-hub": "workspace:*",
"ink": "^6.6.0", "ink": "^6.6.0",
"ps-list": "^9.0.0", "ps-list": "^9.0.0",
"react": "^19.2.3", "react": "^19.2.3",
+1
View File
@@ -52,6 +52,7 @@
}, },
"dependencies": { "dependencies": {
"@hapi/protocol": "workspace:*", "@hapi/protocol": "workspace:*",
"hapi-hub": "workspace:*",
"@modelcontextprotocol/sdk": "^1.25.1", "@modelcontextprotocol/sdk": "^1.25.1",
"@types/cross-spawn": "^6.0.6", "@types/cross-spawn": "^6.0.6",
"@types/ps-list": "^6.2.1", "@types/ps-list": "^6.2.1",
+18
View File
@@ -16,6 +16,7 @@ import { join } from 'node:path';
const scriptDir = import.meta.dir; const scriptDir = import.meta.dir;
const projectRoot = join(scriptDir, '..'); const projectRoot = join(scriptDir, '..');
const repoRoot = join(projectRoot, '..'); const repoRoot = join(projectRoot, '..');
const buildInfoPath = join(repoRoot, 'shared', 'src', 'buildInfo.ts');
// 解析参数 // 解析参数
const args = process.argv.slice(2); 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<void> { async function runWithTimeoutRetry(cmd: string, cwd = projectRoot): Promise<void> {
const timeoutCmd = `timeout 60s ${cmd}`; const timeoutCmd = `timeout 60s ${cmd}`;
while (true) { while (true) {
@@ -93,6 +110,7 @@ async function main(): Promise<void> {
if (!dryRun) { if (!dryRun) {
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
} }
updateBuildInfoVersion(version);
console.log(` ${oldVersion}${version}`); console.log(` ${oldVersion}${version}`);
// Step 2: Build all platform binaries (with embedded web assets) // Step 2: Build all platform binaries (with embedded web assets)
@@ -0,0 +1,174 @@
type WrappedRecord = Record<string, unknown>;
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;
}
+5 -3
View File
@@ -28,12 +28,14 @@ export const hubCommand: CommandDefinition = {
const { host, port } = parseHubArgs(context.commandArgs) const { host, port } = parseHubArgs(context.commandArgs)
if (host) { if (host) {
process.env.WEBAPP_HOST = host process.env.HAPI_LISTEN_HOST = host
} }
if (port) { 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) { } catch (error) {
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error')
if (process.env.DEBUG) { if (process.env.DEBUG) {
+2 -2
View File
@@ -172,8 +172,8 @@ export function getTunwgPath(): string {
return join(runtimePath(), 'tools', 'tunwg', tunwgBinary); 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 platformDir = getPlatformDir();
const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}`; const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}`;
return join(__dirname, '..', '..', '..', 'hub', 'tools', 'tunwg', devBinaryName); return join(__dirname, '..', '..', '..', 'shared', 'tools', 'tunwg', devBinaryName);
} }
+6 -6
View File
@@ -4,7 +4,7 @@ import difftasticArchiveLicense from '../../tools/archives/difftastic-LICENSE' a
import ripgrepArchiveLicense from '../../tools/archives/ripgrep-LICENSE' assert { type: 'file' }; import ripgrepArchiveLicense from '../../tools/archives/ripgrep-LICENSE' assert { type: 'file' };
import difftasticLicense from '../../tools/licenses/difftastic-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 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 { export interface EmbeddedAsset {
relativePath: string; relativePath: string;
@@ -35,7 +35,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([ ] = await Promise.all([
import('../../tools/archives/difftastic-arm64-darwin.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/difftastic-arm64-darwin.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-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 [ return [
...COMMON_ASSETS, ...COMMON_ASSETS,
@@ -53,7 +53,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([ ] = await Promise.all([
import('../../tools/archives/difftastic-x64-darwin.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/difftastic-x64-darwin.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-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 [ return [
...COMMON_ASSETS, ...COMMON_ASSETS,
@@ -71,7 +71,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([ ] = await Promise.all([
import('../../tools/archives/difftastic-arm64-linux.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/difftastic-arm64-linux.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-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 [ return [
...COMMON_ASSETS, ...COMMON_ASSETS,
@@ -89,7 +89,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([ ] = await Promise.all([
import('../../tools/archives/difftastic-x64-linux.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/difftastic-x64-linux.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-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 [ return [
...COMMON_ASSETS, ...COMMON_ASSETS,
@@ -107,7 +107,7 @@ async function selectEmbeddedAssets(): Promise<EmbeddedAsset[]> {
] = await Promise.all([ ] = await Promise.all([
import('../../tools/archives/difftastic-x64-win32.tar.gz', { assert: { type: 'file' } }), import('../../tools/archives/difftastic-x64-win32.tar.gz', { assert: { type: 'file' } }),
import('../../tools/archives/ripgrep-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 [ return [
...COMMON_ASSETS, ...COMMON_ASSETS,
+2 -4
View File
@@ -6,7 +6,7 @@
"es2022" "es2022"
], ],
"jsx": "react", "jsx": "react",
"rootDir": "..", "rootDir": "src",
"experimentalDecorators": true, "experimentalDecorators": true,
"outDir": "dist", "outDir": "dist",
"noEmit": true, "noEmit": true,
@@ -26,8 +26,6 @@
"include": [ "include": [
"src/**/*.ts", "src/**/*.ts",
"src/**/*.tsx", "src/**/*.tsx",
"src/**/*.d.ts", "src/**/*.d.ts"
"../hub/src/**/*.ts",
"../hub/src/**/*.d.ts"
] ]
} }
+6
View File
@@ -6,6 +6,12 @@
"author": "weishu", "author": "weishu",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"exports": {
"./startHub": {
"types": "./src/startHub.d.ts",
"default": "./src/startHub.ts"
}
},
"scripts": { "scripts": {
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"dev": "bun --watch run src/index.ts", "dev": "bun --watch run src/index.ts",
+2 -2
View File
@@ -2,7 +2,7 @@
* Download tunwg binaries for all platforms * Download tunwg binaries for all platforms
* *
* Downloads pre-built tunwg binaries from GitHub releases. * 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'; import { existsSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
@@ -46,7 +46,7 @@ async function main(): Promise<void> {
} else { } else {
scriptDir = dirname(new URL(import.meta.url).pathname); 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'); console.log('Downloading tunwg binaries...\n');
+3 -312
View File
@@ -1,320 +1,11 @@
/** import { startHub } from './startHub'
* 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<string>()
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<WebSocketData> | null = null
let sseManager: SSEManager | null = null
let visibilityTracker: VisibilityTracker | null = null
let notificationHub: NotificationHub | null = null
let tunnelManager: TunnelManager | null = null
async function main() { 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 () => { const shutdown = async () => {
console.log('\nShutting down...') console.log('\nShutting down...')
await tunnelManager?.stop() await hub.stop()
await happyBot?.stop()
notificationHub?.stop()
syncEngine?.stop()
sseManager?.stop()
webServer?.stop()
process.exit(0) process.exit(0)
} }
+9
View File
@@ -0,0 +1,9 @@
export interface HubInstance {
stop(): Promise<void>
}
export interface StartHubOptions {
args?: string[]
}
export function startHub(options?: StartHubOptions): Promise<HubInstance>
+317
View File
@@ -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<string>()
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<void>
}
export interface StartHubOptions {
args?: string[]
}
export async function startHub(options: StartHubOptions = {}): Promise<HubInstance> {
console.log('HAPI Hub starting...')
let syncEngine: SyncEngine | null = null
let happyBot: HappyBot | null = null
let webServer: BunServer<WebSocketData> | 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()
}
}
}
+4 -4
View File
@@ -13,6 +13,7 @@ import { existsSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { platform, arch, homedir } from 'node:os' import { platform, arch, homedir } from 'node:os'
import { isBunCompiled } from '../utils/bunCompiled' import { isBunCompiled } from '../utils/bunCompiled'
import { APP_VERSION } from '@hapi/protocol'
function getHapiHome(): string { function getHapiHome(): string {
return process.env.HAPI_HOME return process.env.HAPI_HOME
@@ -43,15 +44,14 @@ function getTunwgPath(): string {
if (isBunCompiled()) { if (isBunCompiled()) {
const hapiHome = getHapiHome() const hapiHome = getHapiHome()
const packageJson = require('../../../cli/package.json') const runtimePath = join(hapiHome, 'runtime', APP_VERSION)
const runtimePath = join(hapiHome, 'runtime', packageJson.version)
return join(runtimePath, 'tools', 'tunwg', tunwgBinary) 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 platformDir = getPlatformDir()
const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}` const devBinaryName = isWin ? `tunwg-${platformDir}.exe` : `tunwg-${platformDir}`
return join(__dirname, '..', '..', 'tools', 'tunwg', devBinaryName) return join(__dirname, '..', '..', '..', 'shared', 'tools', 'tunwg', devBinaryName)
} }
export interface TunnelConfig { export interface TunnelConfig {
-3
View File
@@ -1,3 +0,0 @@
tunwg/*
!tunwg/
!tunwg/LICENSE
+2 -2
View File
@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"build:site": "bun run --cwd website build && bun run --cwd docs docs:build && cp -r docs/.vitepress/dist website/dist/public/docs", "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", "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": "bun run build:cli && bun run build:web && (cd hub && bun run generate:embedded-web-assets) && bun run build:hub",
"build:cli": "cd cli && bun run build", "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": "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)", "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", "download:tunwg": "bun run hub/scripts/download-tunwg.ts",
+1
View File
@@ -8,6 +8,7 @@
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./messages": "./src/messages.ts", "./messages": "./src/messages.ts",
"./buildInfo": "./src/buildInfo.ts",
"./modes": "./src/modes.ts", "./modes": "./src/modes.ts",
"./schemas": "./src/schemas.ts", "./schemas": "./src/schemas.ts",
"./types": "./src/types.ts", "./types": "./src/types.ts",
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = '0.18.1'
+1
View File
@@ -1,4 +1,5 @@
export * from './messages' export * from './messages'
export * from './buildInfo'
export * from './flavors' export * from './flavors'
export * from './models' export * from './models'
export * from './modes' export * from './modes'
+2 -3
View File
@@ -2,9 +2,8 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { resolve } from 'node:path' 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 base = process.env.VITE_BASE_URL || '/'
const hubTarget = process.env.VITE_HUB_PROXY || 'http://127.0.0.1:3006' 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({ export default defineConfig({
define: { define: {
__APP_VERSION__: JSON.stringify(require('../cli/package.json').version), __APP_VERSION__: JSON.stringify(APP_VERSION),
}, },
server: { server: {
host: true, host: true,