DUNGEONS-34: add one-command app launcher

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-09-06 16:47:05 +05:00
parent 26e6b2247e
commit 2f9f55d600
10 changed files with 374 additions and 14 deletions

170
scripts/dev-all.mjs Normal file
View File

@@ -0,0 +1,170 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { createServer, isIP } from 'node:net';
import { networkInterfaces } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setTimeout as delay } from 'node:timers/promises';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export function parseArgs(args) {
let host = '127.0.0.1', port = '3000';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') return { help: true };
if (arg === '--host' || arg === '--port') {
if (!args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`${arg} needs a value.`);
if (arg === '--host') host = args[++i]; else port = args[++i];
} else if (arg.startsWith('--host=')) host = arg.slice(7);
else if (arg.startsWith('--port=')) port = arg.slice(7);
else throw new Error(`Unknown argument: ${arg}. Use --host and --port (two ordinary hyphens).`);
}
if (host === 'localhost') host = '127.0.0.1';
if (!isIP(host)) throw new Error('--host must be an IPv4/IPv6 address or localhost.');
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('--port must be between 1 and 65535.');
return { host, port: Number(port) };
}
const hostname = host => isIP(host) === 6 ? `[${host}]` : host;
const url = (host, port) => `http://${hostname(host)}:${port}`;
function privateIPv4(ip) {
const parts = ip.split('.').map(Number);
return isIP(ip) === 4 && (parts[0] === 10 || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31));
}
export function addresses({ host, port }, interfaces = networkInterfaces(), env = process.env) {
const wildcard = host === '0.0.0.0' || host === '::';
const local = url(wildcard ? (host === '::' ? '::1' : '127.0.0.1') : host, port);
const loopback = host === '127.0.0.1' || host === '::1' || wildcard;
const lan = wildcard ? [...new Set(Object.values(interfaces).flat().filter(item => item && !item.internal && item.family === 'IPv4' && privateIPv4(item.address)).map(item => url(item.address, port)))] : [];
const origin = env.WEBKA_ORIGIN || lan[0] || (loopback ? `http://localhost:${port}` : local);
const automatic = [origin, local, ...(loopback ? [`http://localhost:${port}`] : []), ...lan];
// Explicit HTTPS/reverse-proxy setups retain their exact configured origin.
const allowed = env.WEBKA_ALLOWED_ORIGINS !== undefined ? env.WEBKA_ALLOWED_ORIGINS.split(',').filter(Boolean) : env.WEBKA_ORIGIN ? [origin] : automatic;
for (const value of [origin, ...allowed]) {
const parsed = new URL(value);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== value || parsed.username || parsed.password) throw new Error(`Use an exact http(s) origin without a path: ${value}`);
}
return { local, lan, origin, allowed: [...new Set(allowed)] };
}
function fingerprint(paths) {
const hash = createHash('sha256');
const ignored = new Set(['node_modules', '.nuxt', '.output', 'dist', '.git']);
function visit(path) {
for (const entry of readdirSync(path, { withFileTypes: true }).sort((a,b) => a.name.localeCompare(b.name))) {
if (ignored.has(entry.name)) continue;
const file = join(path, entry.name);
if (entry.isDirectory()) visit(file);
else if (entry.isFile()) hash.update(file.slice(root.length)).update(readFileSync(file));
}
}
for (const path of paths) visit(path);
return hash.digest('hex');
}
function assertPort(host, port) {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', e => reject(new Error(e.code === 'EADDRINUSE' ? `Port ${port} is already in use. Choose another --port or stop the existing app.` : e.message)));
server.listen({ host, port }, () => server.close(resolve));
});
}
export async function main(args = process.argv.slice(2)) {
const config = parseArgs(args);
if (config.help) {
console.log('Usage: npm run dev:all -- [--host 127.0.0.1] [--port 3000]\nBuilds and runs the frontend + backend together. Ctrl+C stops the app.');
return;
}
const [major, minor] = process.versions.node.split('.').map(Number);
if (major < 22 || (major === 22 && minor < 12)) throw new Error('Webka requires Node.js 22.12 or newer.');
if (spawnSync('cargo', ['--version'], { stdio: 'ignore' }).status !== 0) throw new Error('Rust/Cargo is missing. Install stable Rust, then run this command again.');
if (!process.env.npm_execpath) throw new Error('Start with npm run dev:all so the launcher can install frontend dependencies.');
const urls = addresses(config);
await assertPort(config.host, config.port);
let child = null, stopping = false, killTimer;
function stop() {
stopping = true;
if (child && child.exitCode === null) {
// On Unix, kill only this launcher's process group, including build subprocesses.
if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
else {
try { process.kill(-child.pid, 'SIGINT'); } catch {}
const pid = child.pid;
killTimer = setTimeout(() => { try { process.kill(-pid, 'SIGKILL'); } catch {} }, 5000);
killTimer.unref();
}
}
}
process.on('SIGINT', stop); process.on('SIGTERM', stop);
function run(command, args, env = process.env) {
if (stopping) throw new Error('Startup cancelled.');
const proc = spawn(command, args, { cwd: root, env, stdio: 'inherit', detached: process.platform !== 'win32' });
child = proc;
const completion = new Promise((resolve, reject) => {
proc.once('error', reject);
proc.once('exit', (code, signal) => {
clearTimeout(killTimer); if (child === proc) child = null;
if (code === 0 || stopping) resolve(); else reject(new Error(`${command} failed (${signal || code}).`));
});
});
return { proc, completion };
}
try {
const cacheFile = join(root, '.webka-cache.json');
let cache = {};
try { cache = JSON.parse(readFileSync(cacheFile, 'utf8')); } catch {}
const deps = createHash('sha256').update(readFileSync(join(root, 'frontend/package-lock.json'))).update(readFileSync(join(root, 'frontend/package.json'))).update(`${process.platform}-${process.arch}-${major}`).digest('hex');
if (cache.dependencies !== deps || !existsSync(join(root, 'frontend/node_modules/nuxt/package.json'))) {
console.log('\nInstalling frontend dependencies (first run or changed lockfile)…');
await run(process.execPath, [process.env.npm_execpath, '--prefix', 'frontend', 'ci']).completion;
if (stopping) return;
cache = { dependencies: deps }; writeFileSync(cacheFile, JSON.stringify(cache));
}
const source = fingerprint([join(root, 'frontend')]);
if (cache.frontend !== source || !existsSync(join(root, 'frontend/.output/public/index.html'))) {
console.log('\nBuilding the frontend…');
await run(process.execPath, [process.env.npm_execpath, '--prefix', 'frontend', 'run', 'build']).completion;
if (stopping) return;
cache.frontend = source; writeFileSync(cacheFile, JSON.stringify(cache));
} else console.log('\nFrontend is up to date.');
console.log('\nPreparing the Rust server…');
await run('cargo', ['build', '--locked', '--manifest-path', 'backend/Cargo.toml']).completion;
if (stopping) return;
// Locate Cargo's actual target directory, including CARGO_TARGET_DIR overrides.
const metadata = spawnSync('cargo', ['metadata', '--no-deps', '--format-version', '1', '--manifest-path', 'backend/Cargo.toml'], { cwd: root, encoding: 'utf8' });
if (metadata.status !== 0) throw new Error('Could not locate the Rust executable.');
const executable = join(JSON.parse(metadata.stdout).target_directory, 'debug', process.platform === 'win32' ? 'webka.exe' : 'webka');
const data = resolve(root, process.env.WEBKA_DATA || 'data');
const { proc, completion } = run(executable, [], { ...process.env, WEBKA_BIND: `${hostname(config.host)}:${config.port}`, WEBKA_ORIGIN: urls.origin, WEBKA_ALLOWED_ORIGINS: urls.allowed.join(','), WEBKA_PUBLIC_PORT: String(new URL(urls.origin).port || (urls.origin.startsWith('https:') ? 443 : 80)), WEBKA_ASSETS: join(root, 'frontend/.output/public'), WEBKA_DATA: data });
// Observe exit failures immediately while checking readiness.
let failure;
completion.catch(e => { failure = e; });
let ready = false;
for (let attempt = 0; attempt < 150 && !stopping; attempt++) {
if (failure) throw failure;
if (proc.exitCode !== null) throw new Error('Server exited before becoming ready.');
try {
const response = await fetch(`${urls.local}/api/bootstrap`, { signal: AbortSignal.timeout(1000) });
if (response.ok) { ready = true; break; }
} catch {}
await delay(200);
}
if (!ready && !stopping) { stop(); await completion; throw new Error('The server did not become ready. Check the error above.'); }
if (stopping) { await completion; return; }
console.log(`\nWebka is ready!\n This computer: ${urls.local}`);
for (const address of urls.lan) console.log(` LAN: ${address}`);
if (process.env.WEBKA_ORIGIN) console.log(` Public URL: ${urls.origin}`);
console.log(` Setup token: ${join(data, 'setup-token')}\n Stop: Ctrl+C\n`);
if (urls.lan.length) console.log('Use the localhost/127.0.0.1 URL to capture on this computer. Capturing on a phone requires trusted HTTPS (see README).\n');
await completion;
} finally {
if (child && child.exitCode === null) stop();
process.off('SIGINT', stop); process.off('SIGTERM', stop);
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch(error => { console.error(`\nWebka: ${error.message}`); process.exitCode = 1; });
}

28
scripts/dev-all.test.mjs Normal file
View File

@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs, addresses } from './dev-all.mjs';
test('default and named host/port options', () => {
assert.deepEqual(parseArgs([]), { host: '127.0.0.1', port: 3000 });
assert.deepEqual(parseArgs(['--host', '0.0.0.0', '--port', '3002']), { host: '0.0.0.0', port: 3002 });
assert.deepEqual(parseArgs(['--port=3002', '--host=localhost']), { host: '127.0.0.1', port: 3002 });
for (const args of [['--port', '0'], ['--port', '65536'], ['--port', '3abc'], ['--host', 'evil.example'], ['--host'], ['—port', '3002'], ['--unknown']]) assert.throws(() => parseArgs(args));
});
const networks = { en0: [{ address: '192.168.1.23', family: 'IPv4', internal: false }], other: [{ address: '8.8.8.8', family: 'IPv4', internal: false }], lo: [{ address: '127.0.0.1', family: 'IPv4', internal: true }] };
test('LAN listener keeps camera-capable loopback and explicit LAN origins', () => {
const result = addresses({ host: '0.0.0.0', port: 3002 }, networks, {});
assert.equal(result.origin, 'http://192.168.1.23:3002');
assert.equal(result.local, 'http://127.0.0.1:3002');
assert.deepEqual(result.allowed.sort(), ['http://192.168.1.23:3002', 'http://127.0.0.1:3002', 'http://localhost:3002'].sort());
assert.ok(!result.allowed.some(origin => origin.includes('0.0.0.0') || origin.includes('8.8.8.8')));
});
test('loopback default and IPv6 formatting', () => {
assert.equal(addresses(parseArgs([]), networks, {}).origin, 'http://localhost:3000');
assert.deepEqual(addresses({ host: '::1', port: 3002 }, {}, {}).allowed, ['http://localhost:3002', 'http://[::1]:3002']);
});
test('explicit HTTPS origin does not silently authorize HTTP aliases', () => {
const result = addresses({ host: '0.0.0.0', port: 3002 }, networks, { WEBKA_ORIGIN: 'https://webka.local' });
assert.deepEqual(result.allowed, ['https://webka.local']);
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ORIGIN: 'https://webka.local/login' }));
assert.throws(() => addresses(parseArgs([]), {}, { WEBKA_ALLOWED_ORIGINS: '*' }));
});

92
scripts/test-launcher.py Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""POSIX integration test for the actual npm launcher, including teardown."""
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
root = Path(__file__).resolve().parents[1]
origin = 'http://127.0.0.1:3002'
def stop(proc):
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGINT)
try:
proc.wait(timeout=12)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
raise AssertionError('Launcher did not stop on Ctrl+C')
with tempfile.TemporaryDirectory(prefix='webka-launcher-') as temporary:
data = Path(temporary, 'data')
env = dict(os.environ, WEBKA_DATA=str(data), WEBKA_MDNS='0')
for key in ['WEBKA_ORIGIN', 'WEBKA_ALLOWED_ORIGINS']:
env.pop(key, None)
for iteration in range(2):
logfile = Path(temporary, f'launch-{iteration}.log')
with logfile.open('w') as output:
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--host', '0.0.0.0', '--port', '3002'], cwd=root, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
try:
for _ in range(1200):
if proc.poll() is not None:
raise AssertionError(logfile.read_text())
if 'Webka is ready!' in logfile.read_text():
break
time.sleep(0.5)
else:
raise AssertionError('Startup timed out: ' + logfile.read_text()[-2000:])
if iteration == 1:
assert 'Frontend is up to date.' in logfile.read_text()
assert 'Installing frontend dependencies' not in logfile.read_text()
for route in ['/login', '/host', '/watch', '/settings/accounts', '/settings/general']:
with urllib.request.urlopen(origin + route, timeout=3) as response:
assert response.status == 200
with urllib.request.urlopen(origin + '/api/bootstrap') as response:
assert json.load(response)['needs_setup'] is (iteration == 0)
body = json.dumps({'username': 'owner', 'password': 'launch-test-password-123', 'setup_token': (data / 'setup-token').read_text()}).encode()
if iteration == 0:
req = urllib.request.Request(origin + '/api/bootstrap', data=body, headers={'Origin': origin, 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as response:
assert response.status == 201
req = urllib.request.Request(origin + '/api/login', data=body, headers={'Origin': origin, 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as response:
cookie = response.headers['Set-Cookie'].split(';')[0]
req = urllib.request.Request(origin + '/api/status', headers={'Cookie': cookie})
with urllib.request.urlopen(req) as response:
status = json.load(response)
assert ':3002/watch' in status['local_url']
lan_origin = status['local_url'].removesuffix('/watch')
req = urllib.request.Request(origin + '/api/login', data=body, headers={'Origin': lan_origin, 'Content-Type': 'application/json'})
with urllib.request.urlopen(req) as response:
assert response.status == 200
req = urllib.request.Request(origin + '/api/logout', data=b'{}', headers={'Origin': 'http://evil.example', 'Content-Type': 'application/json', 'Cookie': cookie})
try:
urllib.request.urlopen(req)
raise AssertionError('Untrusted origin accepted')
except urllib.error.HTTPError as error:
assert error.code == 403
finally:
stop(proc)
# Assert that the actual server, not just npm, released the listener.
with socket.socket() as server:
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 3002))
print(f'PASS: launch {iteration + 1}, routes, local/LAN auth, QR port, rejected foreign origin, Ctrl+C cleanup.', flush=True)
with socket.socket() as occupied:
occupied.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
occupied.bind(('127.0.0.1', 3002))
occupied.listen()
proc = subprocess.Popen(['npm', 'run', 'dev:all', '--', '--port', '3002'], cwd=root, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=True)
try:
stdout, stderr = proc.communicate(timeout=20)
assert proc.returncode != 0 and 'already in use' in stderr, stdout + stderr
finally:
stop(proc)
print('PASS: occupied port fails before building or starting a server.', flush=True)