mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Clean up cross-package build coupling
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,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,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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
export interface HubInstance {
|
||||||
|
stop(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartHubOptions {
|
||||||
|
args?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startHub(options?: StartHubOptions): Promise<HubInstance>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
tunwg/*
|
|
||||||
!tunwg/
|
|
||||||
!tunwg/LICENSE
|
|
||||||
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '0.18.1'
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user