Files
LimitlessHPPK 51c53de189 feedback7 G-TOOLS: listen on :: by default so one socket serves IPv6 and IPv4 (PR #291 by @LimitlessHPPK)
Ported only the dual-stack listen from PR #291 (the change accepted as #188). With HOST unset the server binds :: and, if that fails with EAFNOSUPPORT, EADDRNOTAVAIL or EINVAL, retries 0.0.0.0. An explicit HOST, including 0.0.0.0 and 127.0.0.1, is tried once. The boot banner and doctor list shareable IPv6 addresses in brackets, skip fe80::, and keep one URL per /64. An explicit IPv6 literal in the local URL is bracketed. The Windows service listing no longer drops those bracketed lines.

Not ported: /dev/grant, the settings-default migration, and the firewall scripts. The Docker image stays on 0.0.0.0; the alpine fallback was not booted.

Tests: node --test test/ipv6-bind.test.js test/doctor.test.js test/ci.test.js — 20 pass, 0 fail (8 bind, 4 doctor, 8 ci).
2026-10-08 18:53:24 +08:00

141 lines
7.4 KiB
JavaScript
Executable File
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.
#!/usr/bin/env node
// scripts/launch.mjs — cross-platform "prepare + start + open the browser", used by scripts/start-windows.bat,
// scripts/start-windows.ps1 and scripts/start.sh (docs/DEPLOY.md).
//
// node scripts/launch.mjs [--port 3000] [--host ::] [--no-open] [--no-setup] [setup options…]
//
// 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless).
// 2. An update package extracted over the folder (UPDATE.json) is finished (server/update.js; the server does it too,
// for the routes that skip this script); one that does not fit this install stops here with the message.
// 3. node tools/setup.mjs --quiet (dependencies, vendor libs, art download / resume, optional local extraction);
// setup options such as --no-assets, --no-local, --local, --game <dir>, -y are passed through.
// 4. node server/index.js (PORT / HOST from the options or the environment; SP_COMBAT / SP_VERIFY / TRUST_PROXY /
// DEBUG are inherited), then — once /healthz answers — prints the addresses to share and opens
// http://localhost:<port> (not with --no-open, SP_NO_BROWSER=1, or on a Linux box without a display).
// Ctrl+C stops the server (it gets the signal from the terminal itself); the exit code is the server's.
import { spawn, spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
// 打开浏览器只有一份实现(走 shell 关联 = 默认浏览器,且不会把浏览器拉成提权)
import { openBrowser as openInBrowser } from './open-browser.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const IS_WIN = process.platform === 'win32';
if (Number(process.versions.node.split('.')[0]) < 22) {
console.error(`Node.js ${process.versions.node} 太旧,需要 22 或更高(22 / 24 LTS):https://nodejs.org/zh-cn/download`);
process.exit(1);
}
const { c, mark } = await import('../tools/setup.mjs');
const { probePort, classifyAddresses, hostUrl, KIND_LABEL } = await import('../tools/doctor.mjs');
const { applyPendingUpdate, UPDATE_FILE } = await import('../server/update.js');
function parseArgs(argv) {
const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '::', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const [k, v] = a.split('=');
const val = () => (v !== undefined ? v : argv[++i]);
if (k === '--port') o.port = Number(val()) || o.port;
else if (k === '--host') o.host = val() || o.host;
else if (a === '--no-open') o.open = false;
else if (a === '--no-setup') o.setup = false;
else if (a === '-h' || a === '--help') o.help = true;
else if (a === '--game') o.setupArgs.push(a, argv[++i] ?? '');
else o.setupArgs.push(a);
}
return o;
}
function openBrowser(url) {
return openInBrowser(url) !== null;
}
function printShare(port) {
const addrs = classifyAddresses().filter((a) => a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public');
const line = c.dim('─'.repeat(56));
console.log(`\n${line}`);
console.log(`${mark.ok} ${c.bold('服务器已启动')} 本机打开:${c.cyan(`http://localhost:${port}`)}`);
if (addrs.length) {
console.log(' 发给朋友(需要能访问这台电脑的网络):');
for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(hostUrl(a.address, port))} ${c.dim(KIND_LABEL[a.kind])}`);
} else {
console.log(c.warn(' 没有检测到可以分享的地址:朋友暂时无法连接(检查网线/Wi-Fi)。'));
}
console.log(c.dim(' 建房后把 4 位「同盟密钥」或「复制链接」(…/?room=密钥)发给朋友。'));
console.log(c.dim(' 朋友打不开?运行 node tools/doctor.mjs 检查防火墙。按 Ctrl+C 停止服务器。'));
console.log(`${line}\n`);
}
async function waitHealthy(port, child, timeoutMs = 30000) {
const until = Date.now() + timeoutMs;
while (Date.now() < until && child.exitCode === null) {
const p = await probePort(port);
if (p.state === 'ours') return true;
await new Promise((r) => setTimeout(r, 300));
}
return false;
}
async function main() {
const o = parseArgs(process.argv.slice(2));
if (o.help) {
const src = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n');
console.log(src.slice(1, 15).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
return 0;
}
const localUrl = `http://localhost:${o.port}`;
const before = await probePort(o.port, o.host);
if (before.state === 'ours') {
console.log(`${mark.ok} 服务器已经在运行(端口 ${o.port}),直接打开浏览器。`);
if (fs.existsSync(path.join(ROOT, UPDATE_FILE))) {
console.log(c.warn(` 解压过的更新包还没有应用:正在运行的仍是旧版本。先停止它(关掉它的窗口;开机自启用 install-service-windows.ps1 -Restart),再重新启动即可。`));
}
printShare(o.port);
if (o.open) openBrowser(localUrl);
return 0;
}
if (before.state !== 'free') {
console.error(`${mark.err} 端口 ${o.port} 被其他程序占用或无权限(${before.code || before.state})。`);
console.error(` 换一个端口:${IS_WIN ? 'scripts\\start-windows.bat --port 3001' : 'scripts/start.sh --port 3001'}`);
return 1;
}
// before setup: setup and the server then see the new version's files (its message explains a refusal)
if (applyPendingUpdate(ROOT).state === 'failed') return 1;
if (o.setup) {
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'setup.mjs'), '--quiet', ...o.setupArgs], { cwd: ROOT, stdio: 'inherit' });
if (r.status !== 0) { console.error(`${mark.err} 准备步骤失败(见上方)。`); return r.status || 1; }
}
const env = { ...process.env, PORT: String(o.port), HOST: o.host };
const child = spawn(process.execPath, [path.join(ROOT, 'server', 'index.js')], { cwd: ROOT, env, stdio: 'inherit' });
const forward = (sig) => { if (child.exitCode === null) { try { child.kill(sig); } catch { /* gone */ } } };
// SIGINT reaches the server straight from the terminal (same process group / console); forwarding it too would make
// the server's second-signal path force-exit. Other signals (service managers, `kill`) are forwarded.
process.on('SIGINT', () => {});
for (const sig of ['SIGTERM', 'SIGHUP', 'SIGBREAK']) { try { process.on(sig, () => forward(sig === 'SIGBREAK' ? 'SIGTERM' : sig)); } catch { /* unsupported here */ } }
const exited = new Promise((resolve) => child.on('exit', (code, signal) => resolve(code ?? (signal ? 0 : 1))));
if (await waitHealthy(o.port, child)) {
printShare(o.port);
if (o.open && !openBrowser(localUrl)) console.log(c.dim(`(未能自动打开浏览器,请手动访问 ${localUrl})`));
}
return exited;
}
// Same convention as tools/setup.mjs: the helpers above are importable, but only running this file starts anything.
// Without the guard, importing launch.mjs would fall through to main() in the background and bind a port.
function isMain() {
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
}
if (isMain()) {
main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; });
}