303 lines
11 KiB
JavaScript
303 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const fsp = fs.promises;
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const {spawn, execFile} = require('child_process');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const SERVER_FILE = path.join(ROOT, 'server.js');
|
|
const SERVICE_ROOT = path.resolve(process.env.LINK_FIELD_SERVICE_DIR || path.join(os.homedir(), '.local', 'share', 'LinkField', 'service'));
|
|
const PID_FILE = path.join(SERVICE_ROOT, 'server.pid');
|
|
const LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log'));
|
|
const STARTUP_TIMEOUT_MS = 12_000;
|
|
const POLL_INTERVAL_MS = 100;
|
|
const PUBLIC_ENTRIES = Object.freeze([
|
|
'index.html',
|
|
'style.css',
|
|
'favicon.svg',
|
|
'favicon.ico',
|
|
'build-meta.js',
|
|
'runtime-config.js',
|
|
'shared-contracts.js',
|
|
'store-catalog.generated.js',
|
|
'store-catalog.json',
|
|
'puzzle-patterns.js',
|
|
'puzzle-core.js',
|
|
'app-logic.js',
|
|
'archive-codec.js',
|
|
'field-persistence.js',
|
|
'field-persistence-worker.js',
|
|
'puzzle-worker.js',
|
|
'app.js',
|
|
'api-bridge.php',
|
|
'assets',
|
|
'client',
|
|
]);
|
|
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function execFileText(command, args) {
|
|
return new Promise((resolve, reject) => execFile(command, args, {encoding:'utf8'}, (error, stdout) => error ? reject(error) : resolve(stdout)));
|
|
}
|
|
|
|
async function legacyLinkFieldPids() {
|
|
if (process.platform === 'win32') return [];
|
|
let output;
|
|
try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); }
|
|
catch (_) { return []; }
|
|
const matches=[];
|
|
for (const line of output.split(/\r?\n/)) {
|
|
const match=line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/);if(!match)continue;
|
|
const pid=Number(match[1]),executable=path.basename(match[2]).toLowerCase(),command=match[3];
|
|
if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])server\.js(?:\s|$)/.test(command))continue;
|
|
let cwd='';try{cwd=await fsp.readlink(`/proc/${pid}/cwd`)}catch(_){}
|
|
const candidates=[cwd];const absolute=command.match(/(?:^|\s)(\/[^\s]*\/server\.js)(?:\s|$)/);if(absolute)candidates.push(path.dirname(absolute[1]));
|
|
let linkField=false;
|
|
for(const directory of candidates.filter(Boolean)){
|
|
try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(pkg?.name==='link-field-v47-shared-world'){linkField=true;break}}catch(_){}
|
|
}
|
|
if(linkField)matches.push(pid);
|
|
}
|
|
return [...new Set(matches)];
|
|
}
|
|
|
|
async function stopLegacyLinkFieldServers() {
|
|
const pids=await legacyLinkFieldPids();if(!pids.length)return [];
|
|
for(const pid of pids)try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
|
|
const started=Date.now();while(Date.now()-started<3000&&pids.some(isProcessRunning))await sleep(100);
|
|
for(const pid of pids)if(isProcessRunning(pid))try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error}
|
|
return pids;
|
|
}
|
|
|
|
function isProcessRunning(pid) {
|
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return error?.code === 'EPERM';
|
|
}
|
|
}
|
|
|
|
async function readPid() {
|
|
try {
|
|
const pid = Number((await fsp.readFile(PID_FILE, 'utf8')).trim());
|
|
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return null;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function removeStalePid() {
|
|
const pid = await readPid();
|
|
if (pid && isProcessRunning(pid)) return pid;
|
|
await fsp.unlink(PID_FILE).catch(error => {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
});
|
|
return null;
|
|
}
|
|
|
|
function isWithin(parent, child) {
|
|
const relative = path.relative(parent, child);
|
|
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function resolvePublicDir() {
|
|
const explicit = String(process.env.LINK_FIELD_PUBLIC_DIR || '').trim();
|
|
if (explicit) return path.resolve(explicit);
|
|
|
|
const home = os.homedir();
|
|
const defaultDir = path.join(home, 'public_html', 'link-field');
|
|
const defaultParent = path.dirname(defaultDir);
|
|
const rootParent = path.dirname(ROOT);
|
|
const rootName = path.basename(ROOT).toLowerCase();
|
|
const parentName = path.basename(rootParent).toLowerCase();
|
|
|
|
if (path.resolve(ROOT) === path.resolve(defaultDir)) return ROOT;
|
|
if (isWithin(defaultDir, ROOT)) return defaultDir;
|
|
if (parentName === 'link-field' && /^link-field-v\d/i.test(rootName)) return rootParent;
|
|
if (isWithin(defaultParent, ROOT) && /^link-field-v\d/i.test(rootName)) return defaultDir;
|
|
return defaultDir;
|
|
}
|
|
|
|
async function copyEntry(source, destination) {
|
|
const stat = await fsp.stat(source);
|
|
if (stat.isDirectory()) {
|
|
await fsp.mkdir(destination, {recursive:true, mode:0o755});
|
|
const entries = await fsp.readdir(source, {withFileTypes:true});
|
|
for (const entry of entries) {
|
|
await copyEntry(path.join(source, entry.name), path.join(destination, entry.name));
|
|
}
|
|
return;
|
|
}
|
|
if (!stat.isFile()) return;
|
|
await fsp.mkdir(path.dirname(destination), {recursive:true, mode:0o755});
|
|
await fsp.copyFile(source, destination);
|
|
await fsp.chmod(destination, 0o644).catch(() => {});
|
|
}
|
|
|
|
async function deployPublicFiles(publicDir = resolvePublicDir()) {
|
|
await fsp.mkdir(publicDir, {recursive:true, mode:0o755});
|
|
if (path.resolve(publicDir) !== ROOT) {
|
|
for (const entry of PUBLIC_ENTRIES) {
|
|
const source = path.join(ROOT, entry);
|
|
try {
|
|
await copyEntry(source, path.join(publicDir, entry));
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
}
|
|
}
|
|
const manifest = {
|
|
app: 'LinkField',
|
|
version: require('../build-meta').APP_VERSION,
|
|
source: ROOT,
|
|
deployedAt: new Date().toISOString(),
|
|
};
|
|
await fsp.writeFile(path.join(publicDir, '.linkfield-deployment.json'), `${JSON.stringify(manifest, null, 2)}\n`, {encoding:'utf8', mode:0o644});
|
|
return publicDir;
|
|
}
|
|
|
|
async function tailLog(lines = 20) {
|
|
try {
|
|
const text = await fsp.readFile(LOG_FILE, 'utf8');
|
|
return text.trimEnd().split(/\r?\n/).slice(-lines).join('\n');
|
|
} catch (error) {
|
|
return error?.code === 'ENOENT' ? '' : `Could not read log: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
async function waitForStartup(pid, publicDir) {
|
|
const portFile = path.join(publicDir, '.linkfield-port');
|
|
const started = Date.now();
|
|
while (Date.now() - started < STARTUP_TIMEOUT_MS) {
|
|
if (!isProcessRunning(pid)) {
|
|
const log = await tailLog();
|
|
throw new Error(`LinkField exited during startup.${log ? `\n\n${log}` : ''}`);
|
|
}
|
|
try {
|
|
const port = Number((await fsp.readFile(portFile, 'utf8')).trim());
|
|
if (Number.isSafeInteger(port) && port > 0 && port <= 65535) return port;
|
|
} catch (error) {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
}
|
|
await sleep(POLL_INTERVAL_MS);
|
|
}
|
|
const log = await tailLog();
|
|
throw new Error(`LinkField did not finish startup within ${STARTUP_TIMEOUT_MS / 1000} seconds.${log ? `\n\n${log}` : ''}`);
|
|
}
|
|
|
|
async function start() {
|
|
await fsp.mkdir(SERVICE_ROOT, {recursive:true, mode:0o700});
|
|
const existingPid = await removeStalePid();
|
|
if (existingPid) {
|
|
console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`);
|
|
await stop({quiet:true});
|
|
}
|
|
const stoppedLegacy=await stopLegacyLinkFieldServers();
|
|
const publicDir = await deployPublicFiles();
|
|
if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`);
|
|
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
await fsp.mkdir(path.dirname(LOG_FILE), {recursive:true, mode:0o755});
|
|
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
let child;
|
|
try {
|
|
child = spawn(process.execPath, [SERVER_FILE], {
|
|
cwd: ROOT,
|
|
detached: true,
|
|
stdio: ['ignore', logFd, logFd],
|
|
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT},
|
|
});
|
|
} finally {
|
|
fs.closeSync(logFd);
|
|
}
|
|
if (!child.pid) throw new Error('Could not start the LinkField background process.');
|
|
await fsp.writeFile(PID_FILE, `${child.pid}\n`, {encoding:'utf8', mode:0o600});
|
|
child.unref();
|
|
|
|
try {
|
|
const port = await waitForStartup(child.pid, publicDir);
|
|
console.log('LinkField started in the background. The command prompt is available again.');
|
|
console.log(`PID: ${child.pid}`);
|
|
console.log(`Local port: ${port}`);
|
|
console.log(`Public directory: ${publicDir}`);
|
|
console.log(`Log: ${LOG_FILE}`);
|
|
console.log("Check: curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'");
|
|
} catch (error) {
|
|
await fsp.unlink(PID_FILE).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function stop({quiet = false} = {}) {
|
|
const pid = await readPid();
|
|
if (!pid || !isProcessRunning(pid)) {
|
|
await fsp.unlink(PID_FILE).catch(() => {});
|
|
if (!quiet) console.log('LinkField is not running.');
|
|
return false;
|
|
}
|
|
try {
|
|
process.kill(pid, 'SIGTERM');
|
|
} catch (error) {
|
|
if (error?.code !== 'ESRCH') throw error;
|
|
}
|
|
const started = Date.now();
|
|
while (isProcessRunning(pid) && Date.now() - started < 5000) await sleep(100);
|
|
if (isProcessRunning(pid)) {
|
|
try { process.kill(pid, 'SIGKILL'); }
|
|
catch (error) { if (error?.code !== 'ESRCH') throw error; }
|
|
}
|
|
await fsp.unlink(PID_FILE).catch(() => {});
|
|
if (!quiet) console.log(`LinkField stopped (PID ${pid}).`);
|
|
return true;
|
|
}
|
|
|
|
async function status() {
|
|
const pid = await readPid();
|
|
const publicDir = resolvePublicDir();
|
|
if (!pid || !isProcessRunning(pid)) {
|
|
console.log('LinkField is stopped.');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
let port = '';
|
|
try { port = (await fsp.readFile(path.join(publicDir, '.linkfield-port'), 'utf8')).trim(); }
|
|
catch {}
|
|
console.log(`LinkField is running (PID ${pid}${port ? `, port ${port}` : ''}).`);
|
|
console.log(`Public directory: ${publicDir}`);
|
|
console.log(`Log: ${LOG_FILE}`);
|
|
}
|
|
|
|
async function foreground() {
|
|
const publicDir = await deployPublicFiles();
|
|
process.env.LINK_FIELD_PUBLIC_DIR = publicDir;
|
|
const {main} = require('../server');
|
|
await main();
|
|
console.log(`LinkField is running in the foreground. Public directory: ${publicDir}`);
|
|
}
|
|
|
|
async function main() {
|
|
const command = String(process.argv[2] || 'start').toLowerCase();
|
|
if (command === 'start') return start();
|
|
if (command === 'stop') return stop();
|
|
if (command === 'restart') { await stop({quiet:true}); return start(); }
|
|
if (command === 'status') return status();
|
|
if (command === 'foreground') return foreground();
|
|
if (command === 'deploy') {
|
|
const publicDir = await deployPublicFiles();
|
|
console.log(`LinkField public files deployed to ${publicDir}`);
|
|
return;
|
|
}
|
|
throw new Error(`Unknown service command: ${command}`);
|
|
}
|
|
|
|
module.exports = Object.freeze({ROOT, SERVICE_ROOT, PID_FILE, LOG_FILE, PUBLIC_ENTRIES, isProcessRunning, resolvePublicDir, deployPublicFiles, start, stop, status});
|
|
if (require.main === module) main().catch(error => {
|
|
console.error(`LinkField service command failed: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|