cangku/scripts/start-remote.mjs

187 lines
6.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs';
import net from 'node:net';
import path from 'node:path';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { loadConnectionConfig } from './remote-access-common.mjs';
const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access');
const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json');
const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json');
export function buildSshArgs(config) {
return [
'-N', '-T',
'-o', 'BatchMode=yes',
'-o', 'ExitOnForwardFailure=yes',
'-o', 'ServerAliveInterval=30',
'-o', 'ServerAliveCountMax=3',
'-o', 'StrictHostKeyChecking=yes',
'-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`,
'-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`,
'-i', path.join(ACCESS_DIR, 'id_ed25519'),
'-p', String(config.ssh.port),
'-L', `127.0.0.1:${config.localDatabasePort}:${config.ssh.remoteDatabaseHost}:${config.ssh.remoteDatabasePort}`,
`${config.ssh.user}@${config.ssh.host}`,
];
}
function waitForPort(port, timeoutMs = 60000) {
const started = Date.now();
return new Promise((resolve, reject) => {
const attempt = () => {
const socket = net.createConnection({ host: '127.0.0.1', port });
socket.once('connect', () => {
socket.destroy();
resolve();
});
socket.once('error', () => {
socket.destroy();
if (Date.now() - started >= timeoutMs) {
reject(new Error(`SSH 隧道未能在 ${timeoutMs / 1000} 秒内监听端口 ${port}`));
return;
}
setTimeout(attempt, 250);
});
};
attempt();
});
}
function waitForFile(filePath) {
return new Promise((resolve) => {
const timer = setInterval(() => {
if (fs.existsSync(filePath)) {
clearInterval(timer);
resolve();
}
}, 500);
});
}
function waitForExit(child, timeoutMs = 5000) {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
return Promise.race([
new Promise((resolve) => child.once('exit', resolve)),
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
]);
}
export async function main(mode = process.argv[2] || 'tunnel') {
if (!['tunnel', 'dev', 'backend'].includes(mode)) {
throw new Error(`未知启动模式:${mode}`);
}
const children = new Set();
let shuttingDown = false;
let restartingBackend = false;
let backend = null;
const stopChildren = () => {
shuttingDown = true;
for (const child of children) {
if (!child.killed) child.kill('SIGTERM');
}
};
for (const signal of ['SIGINT', 'SIGTERM']) {
process.once(signal, stopChildren);
}
const startTunnel = async () => {
const { value: config } = loadConnectionConfig(ROOT_DIR);
const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' });
children.add(tunnel);
let ready = false;
const earlyExit = new Promise((resolve, reject) => {
tunnel.once('exit', (code) => {
children.delete(tunnel);
if (!ready) {
reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`));
} else if (!shuttingDown) {
console.error(`SSH 隧道已退出(${code ?? 'unknown'})`);
stopChildren();
process.exitCode = code || 1;
}
});
});
try {
await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]);
ready = true;
} catch (error) {
if (!tunnel.killed) tunnel.kill('SIGTERM');
throw error;
}
console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`);
return tunnel;
};
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const startApplication = (args, kind) => {
const childEnvironment = {
...process.env,
CANGKU_REMOTE_SUPERVISOR: 'true',
};
if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) {
childEnvironment.VITE_API_PROXY_TARGET = `http://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`;
}
const child = spawn(npmCommand, args, {
cwd: ROOT_DIR,
stdio: 'inherit',
env: childEnvironment,
});
children.add(child);
child.once('exit', (code) => {
children.delete(child);
if (kind === 'backend' && restartingBackend) return;
if (!shuttingDown) {
console.error(`${kind === 'backend' ? '后端' : '前端'}进程已退出(${code ?? 'unknown'})`);
stopChildren();
process.exitCode = code || 1;
}
});
return child;
};
const hasConnection = fs.existsSync(CONNECTION_PATH);
if (hasConnection) {
if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH);
await startTunnel();
} else if (mode === 'tunnel') {
throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key');
}
if (mode === 'tunnel') return;
backend = startApplication(
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
'backend'
);
if (mode === 'dev') {
startApplication(['--prefix', 'frontend', 'run', 'dev'], 'frontend');
}
if (!hasConnection) {
console.log('首次启动:请在安装页面输入配置请求地址和 API Key。');
await waitForFile(RESTART_MARKER_PATH);
restartingBackend = true;
if (!backend.killed) backend.kill('SIGTERM');
await waitForExit(backend);
restartingBackend = false;
fs.unlinkSync(RESTART_MARKER_PATH);
await startTunnel();
backend = startApplication(
['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'],
'backend'
);
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(`远程运行启动失败:${error.message}`);
process.exitCode = 1;
});
}