DUNGEONS-34: add one-command app launcher
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
170
scripts/dev-all.mjs
Normal file
170
scripts/dev-all.mjs
Normal 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
28
scripts/dev-all.test.mjs
Normal 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
92
scripts/test-launcher.py
Normal 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)
|
||||
Reference in New Issue
Block a user