#!/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; }); }