@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.
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import cache from '../compat/data-cache.cjs';
3
3
  import { dynamicUsage, trackStaticDependency } from '../compat/static-generation.cjs';
4
4
  import { recordCacheDependency } from '../compat/use-cache.cjs';
5
+ import diagnostics from '../compat/diagnostics.cjs';
5
6
 
6
7
  const { cachedValue, deferredValue, optionalRequestContext, cacheState, getCachePaths,
7
8
  inCacheScope, nativeFetch, MAX_CACHE_VALUE_BYTES } = cache;
@@ -294,7 +295,15 @@ export function installFetchCache() {
294
295
  for (let index = dispatcher.handlers.length - 1; index >= 0; index--) {
295
296
  const handler = dispatcher.handlers[index];
296
297
  const context = handler.context();
297
- if (context) return handler.fetch(input, init, context);
298
+ if (context) {
299
+ if (!diagnostics.enabled) return handler.fetch(input, init, context);
300
+ const started = performance.now();
301
+ const fields = { route: diagnostics.route(context), destination: diagnostics.destination(input) };
302
+ return Promise.resolve().then(() => handler.fetch(input, init, context)).then(response => {
303
+ diagnostics.record('fetch', { ...fields, status: response.status, durationMs: performance.now() - started });
304
+ return response;
305
+ }, error => { diagnostics.record('fetch', { ...fields, error: true, durationMs: performance.now() - started }); throw error; });
306
+ }
298
307
  }
299
308
  return nativeFetch(input, init);
300
309
  };
@@ -320,6 +329,8 @@ export function installFetchCache() {
320
329
  const memoize = context.phase === 'render' && method === 'GET' && !explicitSignal;
321
330
  const hardRefresh = !context.production && /(?:^|,)\s*no-cache\s*(?:,|$)/i.test(context.headers?.get?.('cache-control') || '');
322
331
  const persist = policy.persist && !hardRefresh && !context.draftMode && !context.cacheConfig?.forceNoStore;
332
+ if (diagnostics.enabled && !persist) diagnostics.record('cache', { route: diagnostics.route(context), state: 'bypass',
333
+ reason: context.draftMode ? 'draft mode' : hardRefresh ? 'development hard refresh' : context.cacheConfig?.forceNoStore ? 'route disables caching' : 'fetch policy does not opt into persistent caching' });
323
334
  if (!persist && !memoize) return nativeFetch(input, init);
324
335
  const prepared = await prepareRequest(input, init);
325
336
  if (!prepared) return nativeFetch(input, init);
package/runtime/fs.mjs ADDED
@@ -0,0 +1,15 @@
1
+ import { rename as move } from 'node:fs/promises';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+
4
+ // Windows scanners and recently closed file handles can briefly deny rename.
5
+ // Keep the atomic swap: never delete the destination to work around a lock.
6
+ export async function rename(source, destination) {
7
+ const deadline = Date.now() + 2000;
8
+ for (let attempt = 0; ; attempt++) {
9
+ try { return await move(source, destination); }
10
+ catch (error) {
11
+ if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || Date.now() >= deadline) throw error;
12
+ await delay(Math.min(10 * (attempt + 1), 100));
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,164 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, realpath, open } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import path from 'node:path';
5
+ import { initializeHome, rpc, delay, environment, validName, validHealthPath } from './common.mjs';
6
+ import { resolveNativeBinary } from '../../native/resolve.mjs';
7
+
8
+ export const persistentHelp = `Persistent apps (ordinary prn start stays in the foreground):
9
+ prn pstart [directory] [options] Start a built app in the background
10
+ prn prestart [name] [--all] Check a new instance, switch traffic, drain the old
11
+ prn pstop [name] [--all] Stop apps and disable their automatic restoration
12
+ prn pdelete [name] [--all] Stop apps and remove their saved settings
13
+ prn pstatus [name] [--json] Show supervised apps (alias: plist)
14
+ prn plogs [name] [--follow] Read application logs (last 100 lines)
15
+ prn plogs --supervisor Read supervisor logs
16
+ prn pstartup [--remove] Enable or remove restoration at user login
17
+ prn pdown Stop the supervisor; keep apps saved for restoration
18
+
19
+ pstart options: --name NAME --port 3000 --hostname 127.0.0.1 --workers 1
20
+ --profile balanced|speed|memory|classic --inspect
21
+ --health-path / --health-timeout 15000 --drain-timeout 30000 (milliseconds)
22
+ prestart/pstop/plogs default to the app in the current directory.
23
+ plogs options: --lines 100 --follow. App environment comes from its .env files.
24
+ State and logs: ~/.prnext/processes (override with PRNEXT_PM_HOME).
25
+ `;
26
+
27
+ export function parsePersistent(command, argv) {
28
+ const allowed = command === 'pstart' ? ['name', 'port', 'hostname', 'workers', 'profile', 'inspect', 'health-path', 'health-timeout', 'drain-timeout']
29
+ : command === 'plogs' ? ['follow', 'lines', 'supervisor'] : command === 'pstartup' ? ['remove']
30
+ : ['pstatus', 'plist'].includes(command) ? ['json'] : ['prestart', 'pstop', 'pdelete'].includes(command) ? ['all'] : [];
31
+ const flags = new Set(['inspect', 'follow', 'supervisor', 'remove', 'json', 'all']);
32
+ const options = {}; let target;
33
+ for (let index = 0; index < argv.length; index++) {
34
+ const arg = argv[index];
35
+ if (arg.startsWith('-')) {
36
+ const key = arg.slice(2);
37
+ if (!arg.startsWith('--') || !allowed.includes(key)) throw new Error(`Unknown ${command} option: ${arg}`);
38
+ if (key in options) throw new Error(`Duplicate option: ${arg}`);
39
+ if (flags.has(key)) options[key] = true;
40
+ else { const value = argv[++index]; if (!value || value.startsWith('--')) throw new Error(`Provide a value for ${arg}`); options[key] = value; }
41
+ } else { if (target !== undefined) throw new Error(`${command} accepts one ${command === 'pstart' ? 'directory' : 'app name'}`); target = arg; }
42
+ }
43
+ if (target && (options.all || options.supervisor || ['pstartup', 'pdown'].includes(command))) throw new Error('Choose an app name or a global option, not both.');
44
+ for (const key of ['port', 'workers', 'health-timeout', 'drain-timeout', 'lines']) if (key in options) {
45
+ const max = key === 'port' ? 65535 : key === 'workers' ? 64 : key === 'lines' ? 10000 : 60000;
46
+ const min = key.endsWith('timeout') ? 100 : 1;
47
+ if (!/^\d+$/.test(options[key]) || Number(options[key]) < min || Number(options[key]) > max) throw new Error(`--${key} must be between ${min} and ${max}`);
48
+ options[key] = Number(options[key]);
49
+ }
50
+ if (options.name) validName(options.name);
51
+ if (options['health-path']) validHealthPath(options['health-path']);
52
+ if (command !== 'pstart' && target) validName(target);
53
+ return { target, options };
54
+ }
55
+ async function ensure(connection) {
56
+ let running;
57
+ try { running = await rpc(connection, { command: 'ping' }, 1500); }
58
+ catch (error) { if (!['ECONNREFUSED', 'ECONNRESET'].includes(error.code)) throw error; }
59
+ if (!running) {
60
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./watchdog.mjs', import.meta.url)), connection.home], { cwd: connection.home, detached: true, windowsHide: true, stdio: 'ignore', env: environment() });
61
+ child.on('error', () => {}); child.unref();
62
+ }
63
+ for (let i = 0; i < 200; i++) {
64
+ try { const result = await rpc(connection, { command: 'ping' }, 1500); if (result.version !== 1) throw new Error('Supervisor protocol mismatch. Run prn pdown before upgrading.'); if (result.ready) return; }
65
+ catch (error) { if (!['ECONNREFUSED', 'ECONNRESET'].includes(error.code)) throw error; }
66
+ await delay(100);
67
+ }
68
+ throw new Error('Supervisor did not become ready. Check prn plogs --supervisor.');
69
+ }
70
+ async function offline(connection) {
71
+ let saved;
72
+ try { saved = JSON.parse(await readFile(path.join(connection.home, 'apps.json'), 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
73
+ return { daemonPid: null, apps: (saved?.apps || []).map(entry => ({ ...entry.config, enabled: entry.enabled, pid: null, state: 'supervisor-offline', starts: null })) };
74
+ }
75
+ async function tail(file, lines) {
76
+ let text = '', current;
77
+ for (const suffix of ['.3', '.2', '.1', '']) {
78
+ let handle;
79
+ try {
80
+ handle = await open(file + suffix, 'r'); const info = await handle.stat();
81
+ if (!suffix) current = info;
82
+ const size = Math.min(info.size, 1024 * 1024);
83
+ const buffer = Buffer.alloc(size); await handle.read(buffer, 0, size, info.size - size);
84
+ text = (text + buffer.toString()).slice(-1024 * 1024);
85
+ } catch (error) { if (error.code !== 'ENOENT') throw error; } finally { await handle?.close(); }
86
+ }
87
+ return { text: text.split('\n').slice(-(lines + 1)).join('\n'), size: current?.size || 0, ino: current?.ino };
88
+ }
89
+ async function logs(connection, target, options) {
90
+ let name = target;
91
+ if (!options.supervisor && !name) {
92
+ const root = await realpath(process.cwd());
93
+ const saved = await offline(connection);
94
+ const matching = saved.apps.filter(app => app.root === root);
95
+ if (matching.length !== 1) throw new Error('Choose an app name from prn pstatus.');
96
+ name = matching[0].name;
97
+ }
98
+ const file = path.join(connection.home, 'logs', options.supervisor ? 'supervisor.log' : `app-${validName(name)}.log`);
99
+ const output = await tail(file, options.lines || 100);
100
+ process.stdout.write(output.text || 'No logs yet.\n');
101
+ if (!options.follow) return;
102
+ let offset = output.size, ino = output.ino, stopped = false;
103
+ const stop = () => { stopped = true; }; process.once('SIGINT', stop); process.once('SIGTERM', stop);
104
+ try {
105
+ while (!stopped) {
106
+ await delay(250); let handle;
107
+ try {
108
+ handle = await open(file, 'r'); const info = await handle.stat();
109
+ if (info.ino !== ino || info.size < offset) offset = 0;
110
+ ino = info.ino;
111
+ while (offset < info.size && !stopped) {
112
+ const buffer = Buffer.alloc(Math.min(65536, info.size - offset));
113
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
114
+ if (!bytesRead) break; offset += bytesRead;
115
+ if (!process.stdout.write(buffer.subarray(0, bytesRead))) await new Promise(resolve => process.stdout.once('drain', resolve));
116
+ }
117
+ } catch (error) { if (error.code !== 'ENOENT') throw error; } finally { await handle?.close(); }
118
+ }
119
+ } finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); }
120
+ }
121
+ export async function persistentCommand(command, argv) {
122
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(persistentHelp); return; }
123
+ const { target, options } = parsePersistent(command, argv);
124
+ const connection = await initializeHome();
125
+ if (command === 'pstartup') {
126
+ const { startup } = await import('./startup.mjs');
127
+ console.log(await startup(connection.home, { remove: !!options.remove })); return;
128
+ }
129
+ if (command === 'plogs') { await logs(connection, target, options); return; }
130
+ if (command === 'pdown') {
131
+ try {
132
+ const owner = await rpc(connection, { command: 'ping' });
133
+ const result = await rpc(connection, { command: 'shutdown' });
134
+ for (let i = 0; i < 100; i++) {
135
+ let alive = true;
136
+ try { process.kill(owner.pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
137
+ if (!alive) { console.log(result); return; }
138
+ await delay(50);
139
+ }
140
+ throw new Error('Supervisor is still exiting. Check prn pstatus before starting another instance.');
141
+ }
142
+ catch (error) { if (error.code !== 'ECONNREFUSED') throw error; console.log('Supervisor is already stopped.'); }
143
+ return;
144
+ }
145
+ if (['pstatus', 'plist'].includes(command)) {
146
+ let report;
147
+ try { report = await rpc(connection, { command: 'status' }); }
148
+ catch (error) { if (error.code !== 'ECONNREFUSED') throw error; report = await offline(connection); }
149
+ if (target) { report.apps = report.apps.filter(app => app.name === target); if (!report.apps.length) throw new Error(`Unknown persistent app: ${target}`); }
150
+ if (options.json) console.log(JSON.stringify(report, null, 2));
151
+ else if (!report.apps.length) console.log('No persistent apps. Run prn pstart in a built project.');
152
+ else for (const app of report.apps) console.log(`${app.name}: ${app.state} | ${app.hostname}:${app.port} | pid ${app.pid || '—'}${app.error ? ' | ' + app.error : ''}`);
153
+ return;
154
+ }
155
+ let request;
156
+ if (command === 'pstart') {
157
+ const root = await realpath(path.resolve(target || '.'));
158
+ const name = validName(options.name || path.basename(root).replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^[^a-zA-Z0-9]+/, '').slice(0, 64));
159
+ request = { command: 'start', config: { name, root, binary: await resolveNativeBinary(), node: process.execPath, port: options.port || 3000, hostname: options.hostname || '127.0.0.1', workers: options.workers || 1, profile: options.profile || process.env.PRNEXT_PROFILE || 'balanced', inspect: !!options.inspect, healthPath: options['health-path'] || '/', healthTimeout: options['health-timeout'] || 15000, drainTimeout: options['drain-timeout'] || 30000 } };
160
+ } else request = { command: ({ prestart: 'restart', pstop: 'stop', pdelete: 'delete' })[command], name: target, all: !!options.all, root: target || options.all ? undefined : await realpath(process.cwd()) };
161
+ await ensure(connection);
162
+ const report = await rpc(connection, request);
163
+ for (const app of Array.isArray(report) ? report : [report]) console.log(`${app.name}: ${app.state} | ${app.hostname}:${app.port}${app.pid ? ' | pid ' + app.pid : ''}`);
164
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, readFile, writeFile, realpath, rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { rename } from '../fs.mjs';
7
+
8
+ export const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
9
+ export const homeDirectory = () => path.resolve(process.env.PRNEXT_PM_HOME || path.join(homedir(), '.prnext', 'processes'));
10
+ export async function initializeHome(directory = homeDirectory()) {
11
+ await mkdir(directory, { recursive: true, mode: 0o700 });
12
+ const home = await realpath(directory);
13
+ const file = path.join(home, 'control.token');
14
+ try { await writeFile(file, randomBytes(32).toString('hex'), { flag: 'wx', mode: 0o600 }); }
15
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
16
+ let token = '';
17
+ // A second launcher may see the exclusive file between creation and write.
18
+ for (let attempt = 0; attempt < 50; attempt++) {
19
+ token = await readFile(file, 'utf8');
20
+ if (token.length) break;
21
+ await delay(20);
22
+ }
23
+ if (!/^[a-f0-9]{64}$/.test(token)) throw new Error(`Invalid supervisor credentials in ${file}`);
24
+ // A kernel-owned listener arbitrates simultaneous launches. No stale PID file
25
+ // is used to kill a process or steal a filesystem lock after a crash.
26
+ const key = process.platform === 'win32' ? home.toLowerCase() : home;
27
+ const port = 35000 + createHash('sha256').update(key).digest().readUInt16BE(0) % 20000;
28
+ return { home, token, port };
29
+ }
30
+ export async function atomicJSON(file, value) {
31
+ const temporary = `${file}.${randomUUID()}.tmp`;
32
+ try {
33
+ await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
34
+ await rename(temporary, file);
35
+ } finally { await rm(temporary, { force: true }); }
36
+ }
37
+ export function environment() {
38
+ const allowed = /^(?:path|home|userprofile|systemroot|windir|temp|tmp|tmpdir|lang|lc_all|localappdata|appdata|pathext|comspec)$/i;
39
+ return Object.fromEntries(Object.entries(process.env).filter(([key]) => allowed.test(key)));
40
+ }
41
+ export function validName(value) {
42
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(value)) throw new Error('Choose an app name of 1–64 letters, digits, underscores or hyphens, starting with a letter or digit.');
43
+ return value;
44
+ }
45
+ export function validHealthPath(value) {
46
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//') || /[\\\x00-\x20\x7f#]/.test(value) || value.length > 2048) throw new Error('--health-path must be a local URL path such as /api/health');
47
+ return value;
48
+ }
49
+ export async function rpc(connection, request, timeout = 120000) {
50
+ return new Promise((resolve, reject) => {
51
+ const socket = net.connect({ host: '127.0.0.1', port: connection.port });
52
+ socket.setEncoding('utf8');
53
+ let buffer = '', finished = false;
54
+ const finish = (error, value) => { if (finished) return; finished = true; socket.destroy(); error ? reject(error) : resolve(value); };
55
+ socket.setTimeout(timeout, () => finish(new Error('Supervisor request timed out. Check prn plogs --supervisor.')));
56
+ socket.on('error', error => finish(error));
57
+ socket.once('connect', () => socket.write(JSON.stringify({ ...request, token: connection.token }) + '\n'));
58
+ socket.on('data', chunk => {
59
+ buffer += chunk.toString();
60
+ if (buffer.length > 1024 * 1024) return finish(new Error('Supervisor response is too large'));
61
+ if (!buffer.includes('\n')) return;
62
+ try { const result = JSON.parse(buffer.slice(0, buffer.indexOf('\n'))); finish(result.ok ? null : new Error(result.error || 'Supervisor request failed'), result.value); }
63
+ catch (error) { finish(error); }
64
+ });
65
+ socket.on('end', () => { if (!finished) finish(new Error('Supervisor disconnected')); });
66
+ });
67
+ }
@@ -0,0 +1,312 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Agent, createServer, request } from 'node:http';
3
+ import { cp, mkdir, readFile, rm, realpath } from 'node:fs/promises';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import path from 'node:path';
6
+ import { readBuildDirectory } from '../build-directory.mjs';
7
+ import { atomicJSON, delay, environment, validName, validHealthPath } from './common.mjs';
8
+ import { RotatingLog } from './logger.mjs';
9
+
10
+ const home = process.argv[2];
11
+ const registry = path.join(home, 'apps.json');
12
+ const owner = createHash('sha256').update(home).digest('hex').slice(0, 16);
13
+ const apps = new Map();
14
+ let closing = false, sequence = Promise.resolve();
15
+ const send = message => { if (process.connected) process.send(message); };
16
+
17
+ function validate(config) {
18
+ validName(config.name); validHealthPath(config.healthPath);
19
+ if (!path.isAbsolute(config.root) || !path.isAbsolute(config.binary) || !path.isAbsolute(config.node)) throw new Error('Application, Node and native executable paths must be absolute');
20
+ if (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535) throw new Error('--port must be between 1 and 65535');
21
+ if (typeof config.hostname !== 'string' || !config.hostname || /[\s\x00-\x1f]/.test(config.hostname)) throw new Error('Choose a valid --hostname');
22
+ if (!Number.isInteger(config.workers) || config.workers < 1 || config.workers > 64) throw new Error('--workers must be between 1 and 64');
23
+ if (!['balanced', 'speed', 'memory', 'classic', 'standard', 'compact'].includes(config.profile)) throw new Error('Choose --profile balanced, speed, memory or classic');
24
+ for (const key of ['healthTimeout', 'drainTimeout']) if (!Number.isInteger(config[key]) || config[key] < 100 || config[key] > 60000) throw new Error(`${key} must be between 100 and 60000 milliseconds`);
25
+ }
26
+ async function persist() {
27
+ await atomicJSON(registry, { version: 1, apps: [...apps.values()].map(app => ({ config: app.config, enabled: app.enabled, snapshots: [...app.snapshots] })) });
28
+ }
29
+ function newApp(config, enabled = false) {
30
+ return { config, enabled, snapshots: new Set(), active: null, generations: new Set(), gateway: null, sockets: new Set(), state: 'stopped', error: null, starts: 0, failures: [], retry: null,
31
+ log: new RotatingLog(path.join(home, 'logs', `app-${config.name}.log`)) };
32
+ }
33
+ function summary(app) {
34
+ return { name: app.config.name, root: app.config.root, hostname: app.config.hostname, port: app.config.port,
35
+ state: app.state, enabled: app.enabled, pid: app.active?.child.pid || null, starts: app.starts,
36
+ activeRequests: [...app.generations].reduce((sum, generation) => sum + generation.active, 0),
37
+ draining: [...app.generations].filter(generation => generation !== app.active).length,
38
+ buildId: app.active?.buildId || null, startedAt: app.active?.startedAt || null, error: app.error,
39
+ log: path.join(home, 'logs', `app-${app.config.name}.log`) };
40
+ }
41
+ function hopHeaders(headers) {
42
+ const result = { ...headers };
43
+ const nominated = String(headers.connection || '').split(',').map(value => value.trim().toLowerCase());
44
+ for (const key of ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ...nominated]) delete result[key];
45
+ return result;
46
+ }
47
+ async function listen(app) {
48
+ if (app.gateway) return;
49
+ const server = createServer({ requestTimeout: 300000, headersTimeout: 30000 }, (incoming, outgoing) => {
50
+ const generation = app.active;
51
+ if (!incoming.url.startsWith('/')) { outgoing.writeHead(400).end('Use a local request path.'); return; }
52
+ if (!generation || generation.dead) { outgoing.writeHead(503, { 'retry-after': '1' }).end('Application is restarting.'); incoming.resume(); return; }
53
+ if (generation.active >= 512) { outgoing.writeHead(503, { 'retry-after': '1' }).end('Application is busy.'); incoming.resume(); return; }
54
+ generation.active++;
55
+ const headers = hopHeaders(incoming.headers);
56
+ // Preserve original Host and trusted reverse-proxy headers. Never invent
57
+ // retry/replay behavior for POSTs or requests that have already streamed.
58
+ const upstream = request({ host: '127.0.0.1', port: generation.port, method: incoming.method, path: incoming.url, headers, agent: generation.agent }, response => {
59
+ outgoing.writeHead(response.statusCode, hopHeaders(response.headers));
60
+ response.on('error', () => outgoing.destroy()); response.pipe(outgoing);
61
+ });
62
+ generation.requests.add(upstream);
63
+ let released = false;
64
+ const release = () => { if (released) return; released = true; generation.active--; generation.requests.delete(upstream); upstream.destroy(); };
65
+ outgoing.once('finish', release); outgoing.once('close', release);
66
+ incoming.once('aborted', () => outgoing.destroy());
67
+ incoming.once('error', () => outgoing.destroy());
68
+ upstream.once('error', () => { if (!outgoing.headersSent) outgoing.writeHead(502).end('Application connection closed.'); else outgoing.destroy(); });
69
+ incoming.pipe(upstream);
70
+ });
71
+ server.on('upgrade', (_request, socket) => socket.end('HTTP/1.1 501 Not Implemented\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'));
72
+ server.on('connection', socket => { app.sockets.add(socket); socket.once('close', () => app.sockets.delete(socket)); });
73
+ server.maxConnections = 1024;
74
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(app.config.port, app.config.hostname, resolve); });
75
+ server.on('error', error => { app.error = error.message; app.log.message(error.message); });
76
+ app.gateway = server;
77
+ }
78
+ async function removeSnapshot(app, relative) {
79
+ // Only delete a directory generated by this manager inside this exact root.
80
+ const prefix = `.prnext-persistent/${owner}/`;
81
+ if (!relative.startsWith(prefix) || !/^[a-f0-9-]{36}$/.test(relative.slice(prefix.length))) throw new Error('Invalid managed build path');
82
+ const root = await realpath(app.config.root);
83
+ const target = path.join(root, relative);
84
+ const actual = await realpath(target).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
85
+ if (actual && (actual !== target && (process.platform !== 'win32' || actual.toLowerCase() !== target.toLowerCase()))) throw new Error('Managed build path must not be a symbolic link');
86
+ if (actual) await rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
87
+ app.snapshots.delete(relative);
88
+ }
89
+ async function snapshot(app) {
90
+ const root = await realpath(app.config.root);
91
+ const dist = await readBuildDirectory(root);
92
+ if (dist.split('/')[0] === '.prnext-persistent') throw new Error('distDir cannot use .prnext-persistent');
93
+ const source = path.join(root, dist);
94
+ const before = await readFile(path.join(source, 'manifest.json'), 'utf8');
95
+ const manifest = JSON.parse(before);
96
+ if (manifest.dev || manifest.version !== 1 || !Array.isArray(manifest.routes)) throw new Error('Run prn build before prn pstart; a production build is required.');
97
+ const relative = `.prnext-persistent/${owner}/${randomUUID()}`;
98
+ app.snapshots.add(relative); await persist();
99
+ const destination = path.join(root, relative);
100
+ try {
101
+ await mkdir(path.dirname(destination), { recursive: true });
102
+ const container = await realpath(path.dirname(destination));
103
+ if (path.relative(root, container).startsWith('..') || path.isAbsolute(path.relative(root, container))) throw new Error('Managed builds must remain inside the project');
104
+ await cp(source, destination, { recursive: true, verbatimSymlinks: true, filter: file => file !== path.join(source, 'standalone') });
105
+ if (await readFile(path.join(source, 'manifest.json'), 'utf8') !== before || await readFile(path.join(destination, 'manifest.json'), 'utf8') !== before) throw new Error('Build changed while copying. Finish prn build, then retry prn prestart.');
106
+ return { relative, buildId: manifest.buildId || null };
107
+ } catch (error) { await removeSnapshot(app, relative); throw error; }
108
+ }
109
+ function health(generation, config, timeout) {
110
+ return new Promise((resolve, reject) => {
111
+ const req = request({ host: '127.0.0.1', port: generation.port, path: config.healthPath, method: 'GET', headers: { host: `${config.hostname.includes(':') ? '[' + config.hostname + ']' : config.hostname}:${config.port}` }, signal: AbortSignal.timeout(timeout) }, res => {
112
+ const ok = res.statusCode >= 200 && res.statusCode < 400;
113
+ res.destroy(); ok ? resolve() : reject(new Error(`Health check ${config.healthPath} returned HTTP ${res.statusCode}`));
114
+ });
115
+ req.once('error', reject); req.end();
116
+ });
117
+ }
118
+ async function stopGeneration(app, generation, drain = true) {
119
+ if (generation.stopping) return generation.stopping;
120
+ generation.expected = true;
121
+ generation.stopping = (async () => {
122
+ const deadline = Date.now() + (drain ? app.config.drainTimeout : 0);
123
+ while (generation.active && Date.now() < deadline && !generation.dead) await delay(25);
124
+ for (const req of generation.requests) req.destroy();
125
+ generation.agent.destroy();
126
+ generation.child.stdin?.end();
127
+ await Promise.race([generation.closed, delay(3000)]);
128
+ if (!generation.dead) generation.child.kill('SIGKILL');
129
+ if (process.platform !== 'win32' && generation.child.pid) { try { process.kill(-generation.child.pid, 'SIGKILL'); } catch {} }
130
+ await generation.closed;
131
+ app.generations.delete(generation);
132
+ await removeSnapshot(app, generation.relative);
133
+ })();
134
+ return generation.stopping;
135
+ }
136
+ function retry(app, error) {
137
+ if (!app.enabled || closing || app.retry) return;
138
+ app.error = error.message; app.state = 'restarting';
139
+ const now = Date.now(); app.failures = app.failures.filter(time => now - time < 300000); app.failures.push(now);
140
+ if (app.failures.length >= 10) { app.state = 'errored'; app.log.message('Stopped automatic retries after 10 failures in 5 minutes. Run prn prestart to retry.'); return; }
141
+ const wait = Math.min(30000, 500 * 2 ** Math.min(app.failures.length - 1, 6));
142
+ app.log.message(`Restart in ${wait} ms: ${error.message}`);
143
+ app.retry = setTimeout(() => {
144
+ app.retry = null;
145
+ sequence = sequence.then(async () => {
146
+ if (!app.enabled || closing || app.active) return;
147
+ try { await activate(app); } catch (error) { retry(app, error); }
148
+ }).catch(error => app.log.message(error.stack));
149
+ }, wait);
150
+ }
151
+ async function launch(app) {
152
+ const config = app.config;
153
+ const build = await snapshot(app);
154
+ const child = spawn(config.binary, ['start', config.root, '--build-dir', build.relative, '--hostname', '127.0.0.1', '--port', '0', '--node', config.node, '--workers', String(config.workers), '--profile', config.profile, '--shutdown-on-stdin-eof'], {
155
+ cwd: config.root, windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'],
156
+ env: { ...environment(), NODE_ENV: 'production', ...(config.inspect ? { PRNEXT_INSPECT_DIR: path.join(config.root, '.prnext-cache/inspect') } : {}) },
157
+ });
158
+ child.stdin.on('error', () => {});
159
+ const generation = { child, ...build, active: 0, requests: new Set(), agent: new Agent({ keepAlive: true, maxSockets: 512, maxFreeSockets: 16, timeout: 30000 }), dead: false, expected: false, port: null, startedAt: Date.now() };
160
+ app.generations.add(generation);
161
+ if (child.pid) send({ type: 'child', pid: child.pid, running: true });
162
+ let output = '', failure;
163
+ const started = chunk => { output = (output + chunk.toString()).slice(-8192); generation.port ||= Number(/http:\/\/127\.0\.0\.1:(\d+)/.exec(output)?.[1]) || null; };
164
+ child.stdout.on('data', started);
165
+ child.once('error', error => { failure = error; });
166
+ child.once('exit', () => {
167
+ generation.dead = true;
168
+ // On Unix an abruptly killed native parent cannot close descendants itself.
169
+ // Kill its private group before waiting for inherited log pipes to close.
170
+ if (!generation.expected && process.platform !== 'win32' && child.pid) { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }
171
+ });
172
+ app.log.attach(child.stdout, 'stdout'); app.log.attach(child.stderr, 'stderr');
173
+ generation.closed = new Promise(resolve => child.once('close', (code, signal) => {
174
+ generation.dead = true;
175
+ if (child.pid) send({ type: 'child', pid: child.pid, running: false });
176
+ if (app.active === generation) app.active = null;
177
+ if (!generation.expected) {
178
+ app.log.message(`Process ${child.pid || 'unknown'} exited (${signal || code}).`);
179
+ if (generation.ready) {
180
+ // Serialize recovery and cleanup with explicit lifecycle commands.
181
+ sequence = sequence.then(async () => { await stopGeneration(app, generation, false); await persist(); retry(app, new Error(`Application exited (${signal || code})`)); }).catch(error => app.log.message(error.stack));
182
+ }
183
+ }
184
+ resolve();
185
+ }));
186
+ try {
187
+ const deadline = Date.now() + config.healthTimeout;
188
+ while (!generation.port) {
189
+ if (failure || generation.dead) throw failure || new Error('Application exited before listening. Check prn plogs.');
190
+ if (Date.now() >= deadline) throw new Error('Application did not listen before --health-timeout');
191
+ await delay(25);
192
+ }
193
+ await health(generation, config, Math.max(1, deadline - Date.now()));
194
+ if (generation.dead) throw new Error('Application exited during its health check');
195
+ generation.ready = true; app.starts++;
196
+ return generation;
197
+ } catch (error) { await stopGeneration(app, generation, false); throw error; }
198
+ finally { child.stdout.off('data', started); }
199
+ }
200
+ async function activate(app) {
201
+ await listen(app);
202
+ const previous = app.active;
203
+ const wasEnabled = app.enabled;
204
+ let next;
205
+ app.state = previous ? 'reloading' : 'starting';
206
+ try {
207
+ next = await launch(app);
208
+ app.enabled = true;
209
+ await persist();
210
+ if (next.dead) throw new Error('Application exited before traffic could switch');
211
+ app.active = next; app.state = 'online'; app.error = null;
212
+ app.log.message(`Ready: pid ${next.child.pid}, build ${next.buildId || 'unknown'}, ${app.config.hostname}:${app.config.port}`);
213
+ } catch (error) {
214
+ if (next) await stopGeneration(app, next, false).catch(cleanup => app.log.message(cleanup.message));
215
+ app.enabled = wasEnabled;
216
+ app.state = previous && !previous.dead ? 'online' : 'errored'; app.error = error.message;
217
+ await persist().catch(saved => app.log.message(saved.message));
218
+ app.log.message(`Start failed; ${previous && !previous.dead ? 'previous generation remains active' : 'no healthy generation'}: ${error.message}`);
219
+ throw error;
220
+ }
221
+ if (previous) {
222
+ await stopGeneration(app, previous).catch(error => app.log.message(`Previous build cleanup: ${error.message}`));
223
+ await persist();
224
+ }
225
+ }
226
+ async function stopApp(app) {
227
+ app.enabled = false; clearTimeout(app.retry); app.retry = null;
228
+ await persist();
229
+ app.state = 'stopping'; app.active = null;
230
+ const gateway = app.gateway; app.gateway = null;
231
+ const closed = gateway ? new Promise(resolve => gateway.close(resolve)) : Promise.resolve();
232
+ gateway?.closeIdleConnections();
233
+ await Promise.all([...app.generations].map(generation => stopGeneration(app, generation)));
234
+ for (const socket of app.sockets) socket.destroy();
235
+ await closed; app.state = 'stopped'; app.error = null; await persist();
236
+ app.log.message('Stopped.');
237
+ }
238
+ function select(message) {
239
+ const selected = message.all ? [...apps.values()] : [...apps.values()].filter(app => message.name ? app.config.name === message.name : app.config.root === message.root);
240
+ if (!selected.length && !message.all) throw new Error('No matching persistent app. Run prn pstatus to see app names.');
241
+ if (selected.length > 1 && !message.all) throw new Error('Several apps use this directory. Choose an app name.');
242
+ return selected;
243
+ }
244
+ async function command(message) {
245
+ if (message.command === 'status') return { daemonPid: process.pid, apps: [...apps.values()].map(summary) };
246
+ if (message.command === 'start') {
247
+ validate(message.config);
248
+ message.config.root = await realpath(message.config.root);
249
+ let app = apps.get(message.config.name);
250
+ if (app) {
251
+ if (JSON.stringify(app.config) !== JSON.stringify(message.config)) throw new Error('This app name already has different settings. Use prn pdelete, then prn pstart with the new settings.');
252
+ if (app.active) return summary(app);
253
+ } else {
254
+ if (apps.size >= 128) throw new Error('The supervisor supports up to 128 apps');
255
+ if ([...apps.keys()].some(name => name.toLowerCase() === message.config.name.toLowerCase())) throw new Error('App names must be distinct regardless of letter case.');
256
+ app = newApp(message.config); apps.set(message.config.name, app);
257
+ }
258
+ clearTimeout(app.retry); app.retry = null; app.failures = [];
259
+ try { await activate(app); }
260
+ catch (error) { await stopApp(app); app.state = 'errored'; app.error = error.message; throw error; }
261
+ return summary(app);
262
+ }
263
+ const selected = select(message);
264
+ if (message.command === 'restart') {
265
+ for (const app of selected) { clearTimeout(app.retry); app.retry = null; app.failures = []; await activate(app); }
266
+ } else if (message.command === 'stop' || message.command === 'delete') {
267
+ for (const app of selected) { await stopApp(app); if (message.command === 'delete') { apps.delete(app.config.name); app.log.end(); } }
268
+ await persist();
269
+ } else throw new Error('Unknown supervisor command');
270
+ return selected.map(summary);
271
+ }
272
+ async function shutdown() {
273
+ if (closing) return; closing = true;
274
+ await sequence;
275
+ // A supervisor shutdown preserves each enabled flag for the next launch.
276
+ await Promise.all([...apps.values()].map(async app => {
277
+ clearTimeout(app.retry); app.active = null; app.gateway?.close();
278
+ await Promise.all([...app.generations].map(generation => stopGeneration(app, generation)));
279
+ for (const socket of app.sockets) socket.destroy();
280
+ app.log.end();
281
+ }));
282
+ await persist(); process.exit(0);
283
+ }
284
+ process.on('disconnect', () => void shutdown());
285
+ process.on('SIGTERM', () => void shutdown());
286
+ process.on('SIGINT', () => void shutdown());
287
+ process.on('message', message => {
288
+ if (message.type === 'heartbeat') { send({ type: 'heartbeat' }); return; }
289
+ if (message.type === 'shutdown') { void shutdown(); return; }
290
+ if (message.type !== 'request') return;
291
+ const perform = async () => {
292
+ try { if (closing) throw new Error('Supervisor is shutting down'); send({ type: 'reply', id: message.id, ok: true, value: await command(message.request) }); }
293
+ catch (error) { send({ type: 'reply', id: message.id, ok: false, error: error.message }); }
294
+ };
295
+ if (message.request.command === 'status') void perform(); else sequence = sequence.then(perform);
296
+ });
297
+ try {
298
+ let saved;
299
+ try { saved = JSON.parse(await readFile(registry, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; }
300
+ if (saved && (saved.version !== 1 || !Array.isArray(saved.apps) || saved.apps.length > 128)) throw new Error('Unsupported persistent application registry');
301
+ for (const entry of saved?.apps || []) {
302
+ validate(entry.config);
303
+ if ([...apps.keys()].some(name => name.toLowerCase() === entry.config.name.toLowerCase())) throw new Error('Duplicate app names in persistent registry');
304
+ const app = newApp(entry.config, entry.enabled); apps.set(app.config.name, app);
305
+ app.snapshots = new Set(entry.snapshots || []);
306
+ for (const relative of app.snapshots) await removeSnapshot(app, relative).catch(error => app.log.message(error.message));
307
+ }
308
+ sequence = sequence.then(async () => {
309
+ for (const app of apps.values()) if (app.enabled) { try { await activate(app); } catch (error) { retry(app, error); } }
310
+ });
311
+ send({ type: 'ready' });
312
+ } catch (error) { console.error(error.stack); process.exit(1); }
@@ -0,0 +1,52 @@
1
+ import { Writable } from 'node:stream';
2
+ import { mkdir, open, stat, rename, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // Backpressure reaches the child's stdout/stderr; even a noisy app cannot grow
6
+ // an unbounded in-memory write queue. Each file retains three older segments.
7
+ export class RotatingLog extends Writable {
8
+ constructor(file, { maxBytes = 10 * 1024 * 1024, backups = 3 } = {}) {
9
+ super({ highWaterMark: 64 * 1024 });
10
+ this.file = file; this.maxBytes = maxBytes; this.backups = backups; this.size = 0;
11
+ this.on('error', () => {});
12
+ }
13
+ _construct(callback) {
14
+ (async () => {
15
+ await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
16
+ this.size = await stat(this.file).then(info => info.size, () => 0);
17
+ this.handle = await open(this.file, 'a', 0o600);
18
+ })().then(() => callback(), callback);
19
+ }
20
+ async rotate() {
21
+ await this.handle.close(); this.handle = undefined;
22
+ await rm(`${this.file}.${this.backups}`, { force: true });
23
+ for (let i = this.backups - 1; i >= 0; i--) {
24
+ try { await rename(i ? `${this.file}.${i}` : this.file, `${this.file}.${i + 1}`); }
25
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
26
+ }
27
+ this.handle = await open(this.file, 'a', 0o600); this.size = 0;
28
+ }
29
+ _write(chunk, encoding, callback) {
30
+ (async () => {
31
+ let offset = 0;
32
+ while (offset < chunk.length) {
33
+ if (this.size >= this.maxBytes) await this.rotate();
34
+ const count = Math.min(chunk.length - offset, this.maxBytes - this.size);
35
+ const { bytesWritten } = await this.handle.write(chunk.subarray(offset, offset + count));
36
+ if (!bytesWritten) throw new Error('Unable to write application log');
37
+ offset += bytesWritten; this.size += bytesWritten;
38
+ }
39
+ })().then(() => callback(), callback);
40
+ }
41
+ _final(callback) { this.handle?.close().then(() => callback(), callback) ?? callback(); }
42
+ _destroy(error, callback) { this.handle?.close().then(() => callback(error), () => callback(error)) ?? callback(error); }
43
+ message(value) { return this.write(`[${new Date().toISOString()}] ${value}\n`); }
44
+ attach(stream, label) {
45
+ const resume = () => stream.resume();
46
+ stream.on('data', chunk => { if (!this.destroyed && !this.write(Buffer.concat([Buffer.from(`[${label}] `), chunk]))) stream.pause(); });
47
+ this.on('drain', resume);
48
+ const cleanup = () => { this.off('drain', resume); stream.resume(); };
49
+ this.once('error', cleanup);
50
+ stream.once('close', () => { this.off('drain', resume); this.off('error', cleanup); });
51
+ }
52
+ }