471 lines
26 KiB
JavaScript
471 lines
26 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const fsp = fs.promises;
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const http = require('http');
|
|
const crypto = require('crypto');
|
|
const {spawn, execFile} = require('child_process');
|
|
const {createWorldBackup,verifyWorldBackup,restoreWorldBackup}=require('../server/world-backup');
|
|
const {checkPublicDeployment}=require('../server/public-health');
|
|
const {renderApacheBootstrap,replaceManagedBlock}=require('../server/apache-bridge');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const SERVER_FILE = path.join(ROOT, 'server.js');
|
|
const RUNNER_FILE = path.join(ROOT, 'scripts', 'service-runner.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 IDENTITY_FILE = path.join(SERVICE_ROOT, 'service.identity.json');
|
|
const LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log'));
|
|
const TEST_DATA_ROOT=String(process.env.LINK_FIELD_TEST_DATA_ROOT||'').trim();
|
|
const WORLD_DIR=TEST_DATA_ROOT?path.join(path.resolve(TEST_DATA_ROOT),'world'):path.resolve(String(process.env.LINK_FIELD_WORLD_DIR||'/link-field/world'));
|
|
const BACKUP_DIR=path.resolve(String(process.env.LINK_FIELD_BACKUP_DIR||path.join(path.dirname(WORLD_DIR),'backups')));
|
|
const STARTUP_TIMEOUT_MS = 12_000;
|
|
const POLL_INTERVAL_MS = 100;
|
|
const MAX_LOG_BYTES = 10 * 1024 * 1024;
|
|
const LOG_GENERATIONS = 5;
|
|
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)));
|
|
}
|
|
|
|
function rootMatches(value){
|
|
if(typeof value!=='string'||!value.trim())return false;
|
|
const actual=path.resolve(value),expected=ROOT;
|
|
return process.platform==='win32'?actual.toLowerCase()===expected.toLowerCase():actual===expected;
|
|
}
|
|
|
|
async function readWorldInstanceLock(worldDir=WORLD_DIR){
|
|
try{
|
|
const raw=(await fsp.readFile(path.join(worldDir,'server.pid'),'utf8')).trim();
|
|
if(/^\d+$/.test(raw))return{pid:Number(raw),legacy:true};
|
|
const value=JSON.parse(raw);return value&&typeof value==='object'?value:null;
|
|
}catch(_){return null}
|
|
}
|
|
|
|
function isFreshRuntimeRecord(record,maxAgeMs){
|
|
return Boolean(record&&rootMatches(record.root)&&Number.isSafeInteger(Number(record.pid))&&Number(record.pid)>0&&isProcessRunning(Number(record.pid))&&Date.now()-Number(record.heartbeatAt||0)<maxAgeMs);
|
|
}
|
|
|
|
async function isLinkFieldRoot(value){
|
|
if(rootMatches(value))return true;
|
|
if(typeof value!=='string'||!value.trim())return false;
|
|
try{const pkg=JSON.parse(await fsp.readFile(path.join(path.resolve(value),'package.json'),'utf8'));return['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)}catch(_){return false}
|
|
}
|
|
|
|
async function isTrustedRuntimeRecord(record,maxAgeMs){
|
|
const pid=Number(record?.pid),age=Date.now()-Number(record?.heartbeatAt||0);
|
|
return Boolean(Number.isSafeInteger(pid)&&pid>0&&pid!==process.pid&&isProcessRunning(pid)&&age>=-5000&&age<maxAgeMs&&await isLinkFieldRoot(record?.root));
|
|
}
|
|
|
|
async function processExecutableIsNode(pid){
|
|
if(!Number.isSafeInteger(pid)||pid<=0||pid===process.pid||!isProcessRunning(pid))return false;
|
|
if(process.platform==='win32'){
|
|
try{const output=await execFileText('powershell.exe',['-NoProfile','-NonInteractive','-Command',`(Get-Process -Id ${pid} -ErrorAction Stop).Path`]);return['node.exe','nodejs.exe'].includes(path.basename(output.trim()).toLowerCase())}catch(_){return false}
|
|
}
|
|
try{return['node','nodejs'].includes(path.basename(await fsp.readlink(`/proc/${pid}/exe`)).toLowerCase())}catch(_){return false}
|
|
}
|
|
|
|
async function legacyLinkFieldPids({worldDir=WORLD_DIR}={}) {
|
|
const matches=[];
|
|
const [identity,lock]=await Promise.all([readIdentity(),readWorldInstanceLock(worldDir)]);
|
|
if(await isTrustedRuntimeRecord(identity,5_000))matches.push(Number(identity.pid));
|
|
if(await isTrustedRuntimeRecord(lock,15_000))matches.push(Number(lock.pid));
|
|
if(process.platform==='win32'){
|
|
return[...new Set(matches.filter(pid=>pid!==process.pid))];
|
|
}
|
|
let output;
|
|
try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); }
|
|
catch (_) { return [...new Set(matches.filter(pid=>pid!==process.pid))]; }
|
|
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|service-runner\.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(['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)){linkField=true;break}}catch(_){}
|
|
}
|
|
if(linkField)matches.push(pid);
|
|
}
|
|
return [...new Set(matches.filter(pid=>pid!==process.pid))];
|
|
}
|
|
|
|
async function stopLegacyLinkFieldServers() {
|
|
const stopped=new Set(),deadline=Date.now()+5_000;let quietSince=0;
|
|
while(Date.now()<deadline){
|
|
const active=await legacyLinkFieldPids(),pids=active.filter(pid=>!stopped.has(pid));
|
|
for(const pid of pids){
|
|
stopped.add(pid);
|
|
if(!await terminateProcess(pid,2_000))throw new Error(`Older LinkField process PID ${pid} did not stop.`);
|
|
}
|
|
if(active.length||pids.length)quietSince=0;else if(!quietSince)quietSince=Date.now();
|
|
if(quietSince&&Date.now()-quietSince>=1_500)break;
|
|
await sleep(pids.length?300:150);
|
|
}
|
|
const remaining=await legacyLinkFieldPids();
|
|
if(remaining.length)throw new Error(`Older LinkField process PID ${remaining.join(', ')} kept restarting and could not be stopped.`);
|
|
return[...stopped];
|
|
}
|
|
|
|
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 readServiceRecord() {
|
|
try {
|
|
const raw=(await fsp.readFile(PID_FILE, 'utf8')).trim();
|
|
if(/^\d+$/.test(raw)){const pid=Number(raw);return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:null,legacy:true}:null}
|
|
const value=JSON.parse(raw),pid=Number(value?.pid);
|
|
return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:typeof value.nonce==='string'?value.nonce:null,root:value.root}:null;
|
|
} catch (error) {
|
|
if (error?.code === 'ENOENT') return null;
|
|
return null;
|
|
}
|
|
}
|
|
async function readPid(){return(await readServiceRecord())?.pid||null}
|
|
|
|
async function readIdentity(){try{return JSON.parse(await fsp.readFile(IDENTITY_FILE,'utf8'))}catch(error){if(error?.code==='ENOENT')return null;return null}}
|
|
async function isManagedService(pid){
|
|
if(!isProcessRunning(pid))return false;const [identity,record]=await Promise.all([readIdentity(),readServiceRecord()]);
|
|
return Boolean(identity&&record&&record.pid===pid&&identity.pid===pid&&path.resolve(identity.root||'')===ROOT&&path.resolve(record.root||ROOT)===ROOT&&(!record.nonce||identity.nonce===record.nonce)&&Date.now()-Number(identity.heartbeatAt||0)<5000);
|
|
}
|
|
|
|
async function terminateProcess(pid,timeoutMs=5000){
|
|
if(!isProcessRunning(pid))return true;
|
|
try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH')throw error}
|
|
const started=Date.now();while(isProcessRunning(pid)&&Date.now()-started<timeoutMs)await sleep(100);
|
|
if(isProcessRunning(pid)){try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH')throw error}}
|
|
const killedAt=Date.now();while(isProcessRunning(pid)&&Date.now()-killedAt<2000)await sleep(50);
|
|
return !isProcessRunning(pid);
|
|
}
|
|
|
|
async function removeStalePid() {
|
|
const pid = await readPid();
|
|
if (pid && await isManagedService(pid)) return pid;
|
|
const identity=await readIdentity();
|
|
if(isFreshRuntimeRecord(identity,5_000)&&typeof identity.nonce==='string'&&identity.nonce){
|
|
await fsp.writeFile(PID_FILE,`${JSON.stringify({pid:Number(identity.pid),nonce:identity.nonce,root:ROOT})}\n`,{encoding:'utf8',mode:0o600});
|
|
return Number(identity.pid);
|
|
}
|
|
await fsp.unlink(PID_FILE).catch(error => {
|
|
if (error?.code !== 'ENOENT') throw error;
|
|
});
|
|
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
return null;
|
|
}
|
|
|
|
async function recoverWorldLock(){
|
|
await fsp.mkdir(SERVICE_ROOT,{recursive:true,mode:0o700});
|
|
const stopped=new Set(),deadline=Date.now()+15_000;let quietSince=0;
|
|
while(Date.now()<deadline){
|
|
const candidates=new Set(await legacyLinkFieldPids()),identity=await readIdentity(),lock=await readWorldInstanceLock();
|
|
if(identity&&Date.now()-Number(identity.heartbeatAt||0)<5_000&&await processExecutableIsNode(Number(identity.pid)))candidates.add(Number(identity.pid));
|
|
const lockPid=Number(lock?.pid);
|
|
if(Number.isSafeInteger(lockPid)&&lockPid>0&&isProcessRunning(lockPid)){
|
|
if(!await processExecutableIsNode(lockPid))throw new Error(`Refusing recovery: world-lock PID ${lockPid} is not a verified Node process.`);
|
|
candidates.add(lockPid);
|
|
}else if(lock){await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error})}
|
|
const active=[...candidates].filter(pid=>pid!==process.pid&&isProcessRunning(pid));
|
|
if(active.length){quietSince=0;for(const pid of active){if(!await terminateProcess(pid,3_000))throw new Error(`LinkField recovery could not stop PID ${pid}.`);stopped.add(pid)}}
|
|
else if(!quietSince)quietSince=Date.now();
|
|
if(quietSince&&Date.now()-quietSince>=2_500)break;
|
|
await sleep(active.length?300:150);
|
|
}
|
|
const remaining=await readWorldInstanceLock(),remainingPid=Number(remaining?.pid);
|
|
if(Number.isSafeInteger(remainingPid)&&remainingPid>0&&isProcessRunning(remainingPid))throw new Error(`World-lock PID ${remainingPid} is still active after recovery.`);
|
|
await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
await fsp.unlink(PID_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
console.log(stopped.size?`Recovered the LinkField world lock and stopped ${stopped.size} old process${stopped.size===1?'':'es'}.`:'Removed a stale LinkField world lock.');
|
|
console.log('Run npm start now.');
|
|
return[...stopped];
|
|
}
|
|
|
|
async function rotateLogs(){
|
|
let stat;try{stat=await fsp.stat(LOG_FILE)}catch(error){if(error?.code==='ENOENT')return;if(error)throw error}
|
|
if(!stat||stat.size<MAX_LOG_BYTES)return;
|
|
await fsp.unlink(`${LOG_FILE}.${LOG_GENERATIONS}`).catch(error=>{if(error?.code!=='ENOENT')throw error});
|
|
for(let index=LOG_GENERATIONS-1;index>=1;index--){try{await fsp.rename(`${LOG_FILE}.${index}`,`${LOG_FILE}.${index+1}`)}catch(error){if(error?.code!=='ENOENT')throw error}}
|
|
await fsp.rename(LOG_FILE,`${LOG_FILE}.1`);
|
|
}
|
|
|
|
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.lstat(source);
|
|
if(stat.isSymbolicLink())return;
|
|
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(() => {});
|
|
}
|
|
|
|
function assertSafePublicDir(publicDir){
|
|
const resolved=path.resolve(publicDir),root=path.parse(resolved).root,home=path.resolve(os.homedir());
|
|
if(resolved===root||resolved===home||path.dirname(resolved)===resolved)throw new Error(`Unsafe LinkField public directory: ${resolved}`);
|
|
return resolved;
|
|
}
|
|
|
|
async function writeApacheBootstrap(publicDir){
|
|
const apacheFile=path.join(publicDir,'.htaccess');let existing='';
|
|
try{existing=await fsp.readFile(apacheFile,'utf8')}catch(error){if(error?.code!=='ENOENT')throw error}
|
|
await fsp.writeFile(apacheFile,replaceManagedBlock(existing,renderApacheBootstrap()),{encoding:'utf8',mode:0o644});
|
|
}
|
|
|
|
async function deployPublicFiles(publicDir = resolvePublicDir()) {
|
|
publicDir=assertSafePublicDir(publicDir);
|
|
const manifest = {
|
|
app: 'LinkField',
|
|
version: require('../build-meta').APP_VERSION,
|
|
source: ROOT,
|
|
deployedAt: new Date().toISOString(),
|
|
entries:[...PUBLIC_ENTRIES],
|
|
};
|
|
if(path.resolve(publicDir)===ROOT){await writeApacheBootstrap(publicDir);await fsp.writeFile(path.join(publicDir,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});return publicDir}
|
|
const parent=path.dirname(publicDir),name=path.basename(publicDir),stamp=`${process.pid}-${Date.now()}`,stage=path.join(parent,`.${name}.linkfield-stage-${stamp}`),backup=path.join(parent,`.${name}.linkfield-old-${stamp}`);
|
|
await fsp.mkdir(parent,{recursive:true,mode:0o755});
|
|
const artifacts=(await fsp.readdir(parent,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&(entry.name.startsWith(`.${name}.linkfield-stage-`)||entry.name.startsWith(`.${name}.linkfield-old-`))).map(entry=>path.join(parent,entry.name)).sort();
|
|
if(!fs.existsSync(publicDir)){const recoverable=[...artifacts].reverse().find(item=>path.basename(item).startsWith(`.${name}.linkfield-old-`));if(recoverable){await fsp.rename(recoverable,publicDir);artifacts.splice(artifacts.indexOf(recoverable),1)}}
|
|
for(const artifact of artifacts)await fsp.rm(artifact,{recursive:true,force:true});
|
|
try{
|
|
if(fs.existsSync(publicDir))await copyEntry(publicDir,stage);else await fsp.mkdir(stage,{recursive:true,mode:0o755});
|
|
let previous=null;try{previous=JSON.parse(await fsp.readFile(path.join(stage,'.linkfield-deployment.json'),'utf8'))}catch(_){}
|
|
for(const retired of Array.isArray(previous?.entries)?previous.entries.filter(entry=>!PUBLIC_ENTRIES.includes(entry)):[]){const target=path.resolve(stage,retired);if(target.startsWith(`${stage}${path.sep}`))await fsp.rm(target,{recursive:true,force:true})}
|
|
for(const entry of PUBLIC_ENTRIES){const source=path.join(ROOT,entry),destination=path.join(stage,entry);await fsp.rm(destination,{recursive:true,force:true});try{await copyEntry(source,destination)}catch(error){if(error?.code!=='ENOENT')throw error}}
|
|
await writeApacheBootstrap(stage);
|
|
await fsp.writeFile(path.join(stage,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});
|
|
if(fs.existsSync(publicDir))await fsp.rename(publicDir,backup);
|
|
try{await fsp.rename(stage,publicDir)}catch(error){if(fs.existsSync(backup)&&!fs.existsSync(publicDir))await fsp.rename(backup,publicDir);throw error}
|
|
await fsp.rm(backup,{recursive:true,force:true});
|
|
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
|
|
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}`;
|
|
}
|
|
}
|
|
|
|
function localStatus(port){
|
|
return new Promise((resolve,reject)=>{const request=http.get({hostname:'127.0.0.1',port,path:'/api/cloud/status',timeout:1500,headers:{connection:'close'}},response=>{let body='';response.setEncoding('utf8');response.on('data',chunk=>{if(body.length<65536)body+=chunk});response.on('end',()=>{try{const value=JSON.parse(body);if(response.statusCode===200&&value?.available===true&&value?.appVersion===require('../build-meta').APP_VERSION)resolve(value);else reject(new Error(`Local health returned HTTP ${response.statusCode}`))}catch(error){reject(error)}})});request.on('timeout',()=>request.destroy(new Error('Local health timed out')));request.on('error',reject)});
|
|
}
|
|
|
|
async function publicStatus(publicUrl){
|
|
const result=await checkPublicDeployment(publicUrl,{appVersion:require('../build-meta').APP_VERSION,timeoutMs:10_000});return result?.api||null;
|
|
}
|
|
function configuredPublicUrl(){return String(process.env.LINK_FIELD_PUBLIC_URL||'').trim()}
|
|
|
|
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&&await isManagedService(pid)){try{await localStatus(port);return port}catch(_){}}
|
|
} 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();
|
|
let publicDir=null;
|
|
if (existingPid) {
|
|
publicDir = await deployPublicFiles();
|
|
console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`);
|
|
await stop({quiet:true});
|
|
}
|
|
const stoppedLegacy=await stopLegacyLinkFieldServers();
|
|
if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`);
|
|
const unresolvedLock=await readWorldInstanceLock(),unresolvedPid=Number(unresolvedLock?.pid);
|
|
if(Number.isSafeInteger(unresolvedPid)&&unresolvedPid>0&&isProcessRunning(unresolvedPid))throw new Error(`A legacy LinkField world lock is still held by PID ${unresolvedPid}. Run npm run recover once, then run npm start again.`);
|
|
if(!publicDir)publicDir = await deployPublicFiles();
|
|
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});
|
|
await rotateLogs();
|
|
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
let child;
|
|
const nonce=crypto.randomBytes(16).toString('hex');
|
|
try {
|
|
child = spawn(process.execPath, [RUNNER_FILE], {
|
|
cwd: ROOT,
|
|
detached: true,
|
|
stdio: ['ignore', logFd, logFd],
|
|
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT,LINK_FIELD_SERVICE_IDENTITY_FILE:IDENTITY_FILE,LINK_FIELD_SERVICE_NONCE:nonce,LINK_FIELD_LOG_FILE:LOG_FILE},
|
|
});
|
|
} finally {
|
|
fs.closeSync(logFd);
|
|
}
|
|
if (!child.pid) throw new Error('Could not start the LinkField background process.');
|
|
try {
|
|
await fsp.writeFile(PID_FILE, `${JSON.stringify({pid:child.pid,nonce,root:ROOT})}\n`, {encoding:'utf8', mode:0o600});
|
|
child.unref();
|
|
const port = await waitForStartup(child.pid, publicDir);
|
|
const publicEndpoint=await publicStatus(configuredPublicUrl());
|
|
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}`);
|
|
if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);
|
|
console.log(`Check: ${process.execPath} scripts/public-smoke-test.js https://YOUR-HOST/~333/link-field/`);
|
|
} catch (error) {
|
|
const stopped=!child?.pid||await terminateProcess(child.pid).catch(()=>false);
|
|
if(!stopped)throw new Error(`${error.message}\nLinkField supervisor PID ${child.pid} could not be stopped; its PID record was preserved for a safe manual stop.`);
|
|
await fsp.unlink(PID_FILE).catch(() => {});
|
|
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
|
|
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(()=>{});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function stop({quiet = false} = {}) {
|
|
const pid = await readPid();
|
|
if (!pid || !isProcessRunning(pid)) {
|
|
await fsp.unlink(PID_FILE).catch(() => {});
|
|
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
|
|
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
|
|
if (!quiet) console.log('LinkField is not running.');
|
|
return false;
|
|
}
|
|
if(!await isManagedService(pid))throw new Error(`Refusing to stop PID ${pid}: it is not a verified LinkField supervisor.`);
|
|
if(!await terminateProcess(pid))throw new Error(`LinkField supervisor PID ${pid} did not stop.`);
|
|
await fsp.unlink(PID_FILE).catch(() => {});
|
|
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
|
|
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
|
|
if (!quiet) console.log(`LinkField stopped (PID ${pid}).`);
|
|
return true;
|
|
}
|
|
|
|
async function status() {
|
|
const pid = await readPid();
|
|
const publicDir = resolvePublicDir();
|
|
if (!pid || !await isManagedService(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 {}
|
|
let healthy=false,publicEndpoint=null;if(port)try{await localStatus(Number(port));publicEndpoint=await publicStatus(configuredPublicUrl());healthy=true}catch(_){}
|
|
console.log(`LinkField is ${healthy?'healthy':'unhealthy'} (supervisor PID ${pid}${port ? `, port ${port}` : ''}).`);
|
|
console.log(`Public directory: ${publicDir}`);
|
|
console.log(`Log: ${LOG_FILE}`);if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);if(!healthy)process.exitCode=1;
|
|
}
|
|
|
|
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 assertServiceStopped(){const pid=await readPid();if(pid&&await isManagedService(pid))throw new Error('Stop LinkField before running an offline backup or restore.');try{const raw=(await fsp.readFile(path.join(WORLD_DIR,'server.pid'),'utf8')).trim(),lock=/^\d+$/.test(raw)?{pid:Number(raw)}:JSON.parse(raw);if(isProcessRunning(Number(lock?.pid)))throw new Error('A LinkField world process is still active. Stop it before backup or restore.')}catch(error){if(error?.code!=='ENOENT'&&!/Unexpected token|Unexpected end/.test(error?.message||''))throw error}}
|
|
async function backup(){await assertServiceStopped();const result=await createWorldBackup(WORLD_DIR,BACKUP_DIR,{retain:7,appVersion:require('../build-meta').APP_VERSION});console.log(`LinkField backup created: ${result.destination}`);return result}
|
|
async function listBackups(){await fsp.mkdir(BACKUP_DIR,{recursive:true,mode:0o700});const names=(await fsp.readdir(BACKUP_DIR,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();for(const name of names){try{const result=await verifyWorldBackup(BACKUP_DIR,name);console.log(`${name} revision=${result.manifest.worldRevision} files=${result.manifest.fileCount}`)}catch(error){console.log(`${name} INVALID ${error.message}`)}}return names}
|
|
async function restore(name){await assertServiceStopped();if(!name)throw new Error('Specify a backup name from npm run backups.');const result=await restoreWorldBackup(WORLD_DIR,BACKUP_DIR,name);console.log(`LinkField restored backup ${result.restored}. Previous data remains at ${result.previous}`);return result}
|
|
|
|
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 === 'backup') return backup();
|
|
if (command === 'backups') return listBackups();
|
|
if (command === 'restore') return restore(process.argv[3]);
|
|
if (command === 'recover') return recoverWorldLock();
|
|
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, IDENTITY_FILE, LOG_FILE, WORLD_DIR, BACKUP_DIR, PUBLIC_ENTRIES, isProcessRunning,legacyLinkFieldPids,recoverWorldLock,resolvePublicDir,deployPublicFiles,start,stop,status,backup,listBackups,restore});
|
|
if (require.main === module) main().catch(error => {
|
|
console.error(`LinkField service command failed: ${error.message}`);
|
|
process.exitCode = 1;
|
|
});
|