@thomas.f/prnext 0.1.0-alpha.1 → 0.1.1-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ import { mkdir, writeFile, readFile, rm } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { homedir } from 'node:os';
5
+ import { createHash } from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import path from 'node:path';
8
+
9
+ const execute = promisify(execFile);
10
+ const ps = value => `'${value.replaceAll("'", "''")}'`;
11
+ const xml = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
12
+ const systemd = value => '"' + value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('$', () => '$$') + '"';
13
+ // Windows CommandLineToArgvW quoting, followed separately by PowerShell quoting.
14
+ const windows = value => '"' + value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
15
+
16
+ export function startupDefinition({ platform = process.platform, home, node = process.execPath, watchdog = fileURLToPath(new URL('./watchdog.mjs', import.meta.url)), userHome = homedir(), remove = false }) {
17
+ for (const value of [home, node, watchdog, userHome]) if (/[\x00-\x1f\x7f]/.test(value)) throw new Error('Startup paths cannot contain control characters');
18
+ const id = createHash('sha256').update(platform === 'win32' ? home.toLowerCase() : home).digest('hex').slice(0, 16);
19
+ const marker = `PRNext persistent supervisor ${id}`;
20
+ const name = `prnext-${id}`;
21
+ if (platform === 'linux') {
22
+ const file = path.join(userHome, '.config/systemd/user', `${name}.service`);
23
+ return { marker, file, content: `# ${marker}\n[Unit]\nDescription=${marker}\n\n[Service]\nType=simple\nExecStart=${[node, watchdog, home].map(systemd).join(' ')}\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=70\nKillMode=control-group\n\n[Install]\nWantedBy=default.target\n`,
24
+ commands: remove ? [['systemctl', ['--user', 'disable', `${name}.service`]]] : [['systemctl', ['--user', 'daemon-reload']], ['systemctl', ['--user', 'enable', `${name}.service`]]],
25
+ note: 'Restoration enabled for your next user login. For startup before login on a server, an administrator can enable linger for this account (loginctl enable-linger). No linger setting was changed.' };
26
+ }
27
+ if (platform === 'darwin') {
28
+ const file = path.join(userHome, 'Library/LaunchAgents', `dev.prnext.${id}.plist`);
29
+ return { marker, file, content: `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key><string>dev.prnext.${id}</string>\n<key>Description</key><string>${marker}</string>\n<key>ProgramArguments</key><array>${[node, watchdog, home].map(value => '<string>' + xml(value) + '</string>').join('')}</array>\n<key>RunAtLoad</key><true/>\n<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n<key>ThrottleInterval</key><integer>5</integer>\n<key>ExitTimeOut</key><integer>70</integer>\n</dict></plist>\n`, commands: [],
30
+ note: 'Restoration enabled for your next macOS login. The LaunchAgent restarts a failed supervisor while your session is active.' };
31
+ }
32
+ if (platform === 'win32') {
33
+ const file = path.join(home, 'startup.ps1');
34
+ const launch = path.join(home, 'launch.ps1');
35
+ const launcher = `# ${marker}\n$ErrorActionPreference = 'Stop'\n$prnextProcess = Start-Process -FilePath ${ps(node)} -ArgumentList ${ps([watchdog, home].map(windows).join(' '))} -WindowStyle Hidden -Wait -PassThru\nexit $prnextProcess.ExitCode\n`;
36
+ const args = `-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File ${windows(launch)}`;
37
+ const content = `# ${marker}\n$ErrorActionPreference = 'Stop'\n$prnextTaskName = ${ps(name)}\n$prnextExisting = Get-ScheduledTask -TaskName $prnextTaskName -ErrorAction SilentlyContinue\nif ($prnextExisting -and $prnextExisting.Description -ne ${ps(marker)}) { throw 'This task name belongs to another application.' }\n` + (remove
38
+ ? `if ($prnextExisting) { Unregister-ScheduledTask -TaskName $prnextTaskName -Confirm:$false }\n`
39
+ : `$prnextUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name\n$prnextAction = New-ScheduledTaskAction -Execute (Join-Path $PSHOME 'powershell.exe') -Argument ${ps(args)}\n$prnextTrigger = New-ScheduledTaskTrigger -AtLogOn -User $prnextUser\n$prnextPrincipal = New-ScheduledTaskPrincipal -UserId $prnextUser -LogonType Interactive -RunLevel Limited\n$prnextSettings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries\nRegister-ScheduledTask -TaskName $prnextTaskName -Description ${ps(marker)} -Action $prnextAction -Trigger $prnextTrigger -Principal $prnextPrincipal -Settings $prnextSettings -Force | Out-Null\n`);
40
+ return { marker, file, content, extra: { file: launch, content: launcher }, commands: [['powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', file]]],
41
+ note: 'Restoration enabled for your next Windows login, using a hidden task for your account. It retries supervisor failures every minute. This is not a service that runs before login.' };
42
+ }
43
+ throw new Error('Automatic startup supports Windows, macOS and Linux with systemd.');
44
+ }
45
+ async function owned(file, marker) {
46
+ try { if (!(await readFile(file, 'utf8')).includes(marker)) throw new Error(`Refusing to replace a startup file not owned by PRNext: ${file}`); }
47
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
48
+ }
49
+ export async function startup(home, { remove = false } = {}) {
50
+ const definition = startupDefinition({ home, remove });
51
+ await owned(definition.file, definition.marker);
52
+ if (definition.extra) await owned(definition.extra.file, definition.marker);
53
+ if (!remove || process.platform === 'win32') {
54
+ await mkdir(path.dirname(definition.file), { recursive: true, mode: 0o700 });
55
+ // Windows PowerShell 5.1 needs a UTF-8 BOM to preserve non-ASCII paths.
56
+ const encodingMarker = process.platform === 'win32' ? '\ufeff' : '';
57
+ await writeFile(definition.file, encodingMarker + definition.content, { mode: 0o600 });
58
+ if (definition.extra && !remove) await writeFile(definition.extra.file, encodingMarker + definition.extra.content, { mode: 0o600 });
59
+ }
60
+ for (const [file, args] of definition.commands) {
61
+ try { await execute(file, args, { windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024 }); }
62
+ catch (error) { throw new Error(`Unable to ${remove ? 'remove' : 'enable'} startup: ${error.stderr?.trim() || error.message}. The generated definition is at ${definition.file}.`); }
63
+ }
64
+ if (remove) {
65
+ await rm(definition.file, { force: true });
66
+ if (definition.extra) await rm(definition.extra.file, { force: true });
67
+ if (process.platform === 'linux') await execute('systemctl', ['--user', 'daemon-reload'], { timeout: 30000 });
68
+ return 'Automatic startup removed. Running apps remain available; use prn pstop to stop them.';
69
+ }
70
+ return definition.note + '\nRun prn pstartup again after moving or upgrading Node or PRNext.';
71
+ }
@@ -0,0 +1,109 @@
1
+ import { fork } from 'node:child_process';
2
+ import { createServer } from 'node:net';
3
+ import { timingSafeEqual } from 'node:crypto';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { initializeHome, environment, delay } from './common.mjs';
7
+ import { RotatingLog } from './logger.mjs';
8
+
9
+ const connection = await initializeHome(process.argv[2]);
10
+ const log = new RotatingLog(path.join(connection.home, 'logs', 'supervisor.log'));
11
+ const pending = new Map(), children = new Set(), sockets = new Set();
12
+ const shutdownReplies = [];
13
+ let daemon, ready = false, closing = false, counter = 0, heartbeat = Date.now(), failures = 0, restartTimer;
14
+ const server = createServer(socket => {
15
+ socket.setEncoding('utf8');
16
+ sockets.add(socket); socket.once('close', () => sockets.delete(socket));
17
+ socket.setTimeout(125000, () => socket.destroy());
18
+ let buffer = '', received = false;
19
+ socket.on('error', () => {});
20
+ socket.on('data', chunk => {
21
+ if (received) return;
22
+ buffer += chunk.toString();
23
+ if (buffer.length > 65536) { socket.destroy(); return; }
24
+ if (!buffer.includes('\n')) return;
25
+ received = true;
26
+ let message;
27
+ const reply = result => socket.end(JSON.stringify(result) + '\n');
28
+ try {
29
+ message = JSON.parse(buffer.slice(0, buffer.indexOf('\n')));
30
+ const supplied = Buffer.from(typeof message.token === 'string' ? message.token : '');
31
+ const expected = Buffer.from(connection.token);
32
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error('Invalid supervisor credentials');
33
+ delete message.token;
34
+ if (message.command === 'ping') { reply({ ok: true, value: { version: 1, pid: process.pid, daemonPid: daemon?.pid || null, ready } }); return; }
35
+ if (message.command === 'shutdown') { shutdownReplies.push(reply); void shutdown(); return; }
36
+ if (!ready || !daemon?.connected) throw new Error('Supervisor is recovering. Retry shortly.');
37
+ if (pending.size >= 32) throw new Error('Supervisor is busy. Retry shortly.');
38
+ const id = ++counter;
39
+ pending.set(id, reply);
40
+ socket.once('close', () => pending.delete(id));
41
+ daemon.send({ type: 'request', id, request: message }, error => { if (error) { pending.delete(id); reply({ ok: false, error: error.message }); } });
42
+ } catch (error) { reply({ ok: false, error: error.message }); }
43
+ });
44
+ });
45
+ server.maxConnections = 64;
46
+ try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(connection.port, '127.0.0.1', resolve); }); }
47
+ catch (error) {
48
+ // Concurrent pstart calls can create two candidates. Only the listener owner
49
+ // starts a daemon; a losing candidate never writes into the shared log.
50
+ console.error(`Cannot bind supervisor control port ${connection.port}: ${error.message}`);
51
+ log.destroy(); process.exit(1);
52
+ }
53
+ function killChildren() {
54
+ for (const pid of children) { try { process.kill(process.platform === 'win32' ? pid : -pid, 'SIGKILL'); } catch {} }
55
+ children.clear();
56
+ }
57
+ function launch() {
58
+ if (closing) return;
59
+ ready = false; heartbeat = Date.now();
60
+ const child = fork(fileURLToPath(new URL('./daemon.mjs', import.meta.url)), [connection.home], {
61
+ cwd: connection.home, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe', 'ipc'], env: environment(), execArgv: [],
62
+ });
63
+ daemon = child;
64
+ log.attach(child.stdout, 'supervisor'); log.attach(child.stderr, 'supervisor');
65
+ const started = Date.now();
66
+ child.on('message', message => {
67
+ if (message.type === 'ready') { ready = true; log.message(`Supervisor ready: watchdog ${process.pid}, daemon ${child.pid}`); }
68
+ if (message.type === 'heartbeat') heartbeat = Date.now();
69
+ if (message.type === 'child') { if (message.running) children.add(message.pid); else children.delete(message.pid); }
70
+ if (message.type === 'reply') { pending.get(message.id)?.(message); pending.delete(message.id); }
71
+ });
72
+ child.on('error', error => log.message(error.message));
73
+ child.once('exit', (code, signal) => {
74
+ ready = false; killChildren();
75
+ for (const reply of pending.values()) reply({ ok: false, error: 'Supervisor restarted during the command. Check prn pstatus before retrying.' });
76
+ pending.clear();
77
+ if (closing) return;
78
+ failures = Date.now() - started > 60000 ? 1 : failures + 1;
79
+ const wait = Math.min(30000, 500 * 2 ** Math.min(failures - 1, 6));
80
+ log.message(`Supervisor exited (${signal || code}); restart in ${wait} ms.`);
81
+ restartTimer = setTimeout(launch, wait);
82
+ });
83
+ }
84
+ const pulse = setInterval(() => {
85
+ if (!daemon?.connected || closing) return;
86
+ if (Date.now() - heartbeat > 15000) { log.message('Supervisor stopped responding; restarting.'); daemon.kill('SIGKILL'); }
87
+ else daemon.send({ type: 'heartbeat' }, () => {});
88
+ }, 1000);
89
+ async function shutdown() {
90
+ if (closing) return; closing = true; ready = false;
91
+ clearTimeout(restartTimer); clearInterval(pulse);
92
+ if (daemon?.connected) {
93
+ const exited = new Promise(resolve => daemon.once('exit', resolve));
94
+ daemon.send({ type: 'shutdown' }, () => {});
95
+ await Promise.race([exited, delay(65000)]);
96
+ if (daemon.exitCode === null && !daemon.signalCode) daemon.kill('SIGKILL');
97
+ await exited;
98
+ }
99
+ killChildren();
100
+ log.message('Supervisor stopped.');
101
+ await new Promise(resolve => log.end(resolve));
102
+ server.close();
103
+ for (const reply of shutdownReplies) reply({ ok: true, value: 'Supervisor stopped; enabled apps will return on the next launch.' });
104
+ await delay(50);
105
+ for (const socket of sockets) socket.destroy();
106
+ process.exit(0);
107
+ }
108
+ process.once('SIGTERM', () => void shutdown()); process.once('SIGINT', () => void shutdown());
109
+ launch();
@@ -1,4 +1,4 @@
1
- import { watch as watchDirectory } from 'node:fs';
1
+ import { watch as watchDirectory, statSync } from 'node:fs';
2
2
  import { readdir } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { shouldWatchProjectFile } from './env.mjs';
@@ -9,13 +9,20 @@ const missing = error => error.code === 'ENOENT' || error.code === 'ENOTDIR';
9
9
  // including ignored build trees. Atomic build swaps can delete a descendant
10
10
  // during that scan. Watch only source directories, and handle removal races
11
11
  // in our own traversal. No file contents or node_modules watches are retained.
12
- export async function watchProject(root, { onChange, onError, ignore = () => false, watch = watchDirectory, maxDirectories = 8192, nativeRecursive = process.platform === 'darwin' }) {
13
- // macOS has native recursive FSEvents support. Retain that backend: separate
14
- // directory subscriptions can lose coalesced events during rapid renames.
12
+ export async function watchProject(root, { onChange, onError, ignore = () => false, watch = watchDirectory, maxDirectories = 8192, nativeRecursive = process.platform === 'darwin' || process.platform === 'win32' }) {
13
+ // macOS and Windows have native recursive backends. Separate directory
14
+ // subscriptions can lose coalesced events during rapid renames.
15
15
  if (nativeRecursive) {
16
- const watcher = watch(root, { recursive: true }, (_event, filename) => {
16
+ const watcher = watch(root, { recursive: true }, (event, filename) => {
17
17
  const relative = filename && String(filename).replaceAll(path.sep, '/');
18
- if (relative && !ignore(relative) && shouldWatchProjectFile(relative)) onChange(relative);
18
+ if (!relative || ignore(relative) || !shouldWatchProjectFile(relative)) return;
19
+ if (process.platform === 'win32' && event === 'change') {
20
+ // ReadDirectoryChangesW reports directory metadata changes while the
21
+ // compiler reads sources. Creation/removal still arrives as rename.
22
+ // Rebuilding on those reads restarts the server (and its SSE stream).
23
+ try { if (statSync(path.join(root, relative)).isDirectory()) return; } catch {}
24
+ }
25
+ onChange(relative);
19
26
  });
20
27
  watcher.on('error', error => { watcher.close(); onError(error); });
21
28
  return { close: () => watcher.close(), async refresh() {} };