service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. #!/usr/bin/env bun
  2. import { execSync, spawn } from 'child_process';
  3. import * as fs from 'fs';
  4. import { dirname, basename, join, resolve } from 'path';
  5. import * as zlib from 'zlib';
  6. import * as readline from 'readline';
  7. import type { Readable } from 'stream';
  8. type RunnerFunction = (options: RunnerOptions) => (() => void) | void;
  9. type Logger = {
  10. log: (...args: any[]) => void;
  11. trace: (...args: any[]) => void;
  12. debug: (...args: any[]) => void;
  13. info: (...args: any[]) => void;
  14. warn: (...args: any[]) => void;
  15. error: (...args: any[]) => void;
  16. };
  17. type RunnerOptions = {
  18. logger: Logger;
  19. };
  20. type ServiceOptions = {
  21. name: string;
  22. description: string;
  23. runner: RunnerFunction;
  24. };
  25. export default function service(options: ServiceOptions) {
  26. const serviceName = options.name;
  27. const serviceDescription = options.description;
  28. const runner = options.runner;
  29. // Paths
  30. const lockFile = `/tmp/${serviceName}.pid`;
  31. const logDir = `/tmp/`;
  32. const logFile = join(logDir, `${serviceName}.log`);
  33. const maxLogSize = 5 * 1024 * 1024; // 5 MB per log file
  34. const maxTotalLogSize = 20 * 1024 * 1024; // 20 MB total for all logs
  35. const serviceConfig = `/etc/systemd/system/${serviceName}.service`;
  36. // Get the absolute path to the current executable
  37. const selfFile = resolve(process.argv0);
  38. const selfDir = dirname(selfFile);
  39. const selfFilename = basename(selfFile);
  40. // Function to check if systemd is available
  41. function hasSystemd() {
  42. try {
  43. execSync('which systemctl', { stdio: 'ignore' });
  44. return fs.existsSync('/run/systemd/system');
  45. } catch {
  46. return false;
  47. }
  48. }
  49. // Function to check if the systemd service file exists
  50. function hasSystemdService() {
  51. return fs.existsSync(serviceConfig);
  52. }
  53. // Log stream variable
  54. let logStream: fs.WriteStream | null = null;
  55. // Function to create the logger
  56. function createLogger(): Logger {
  57. // Determine if running under systemd
  58. const useSystemdLogging = hasSystemdService();
  59. // If not using systemd, set up log file
  60. if (!useSystemdLogging) {
  61. // Open the log file for appending
  62. logStream = fs.createWriteStream(logFile, { flags: 'a' });
  63. }
  64. // Function to write logs and handle rotation
  65. function writeLog(message: string, severity: string = 'INFO') {
  66. const formattedMessage = useSystemdLogging
  67. // Systemd includes its own timestamp
  68. ? `[${severity}] ${message}\n`
  69. // Custom timestamp
  70. : `${new Date().toISOString()} [${severity}] ${message}\n`;
  71. if (useSystemdLogging) {
  72. // Write to stdout or stderr based on severity
  73. if (severity === 'ERROR' || severity === 'WARN') {
  74. process.stderr.write(formattedMessage);
  75. } else {
  76. process.stdout.write(formattedMessage);
  77. }
  78. } else {
  79. rotateLogs();
  80. if (logStream) {
  81. try {
  82. logStream.write(formattedMessage);
  83. } catch {
  84. // If writing fails, fallback to stdout/stderr
  85. process.stdout.write(formattedMessage);
  86. }
  87. } else {
  88. // If logStream is closed, write to stdout/stderr
  89. process.stdout.write(formattedMessage);
  90. }
  91. }
  92. }
  93. // Return the logger object
  94. const logger: Logger = {
  95. log: (...args: any[]) => {
  96. writeLog(args.join(' '), 'INFO');
  97. },
  98. trace: (...args: any[]) => {
  99. writeLog(args.join(' '), 'TRACE');
  100. },
  101. debug: (...args: any[]) => {
  102. writeLog(args.join(' '), 'DEBUG');
  103. },
  104. info: (...args: any[]) => {
  105. writeLog(args.join(' '), 'INFO');
  106. },
  107. warn: (...args: any[]) => {
  108. writeLog(args.join(' '), 'WARN');
  109. },
  110. error: (...args: any[]) => {
  111. writeLog(args.join(' '), 'ERROR');
  112. },
  113. };
  114. return logger;
  115. }
  116. // Function to close the logger
  117. function closeLogger() {
  118. if (logStream) {
  119. logStream.end();
  120. logStream = null;
  121. }
  122. }
  123. // Function to rotate logs
  124. function rotateLogs() {
  125. if (fs.existsSync(logFile)) {
  126. const stats = fs.statSync(logFile);
  127. if (stats.size >= maxLogSize) {
  128. // Close the existing log stream before renaming
  129. if (logStream) {
  130. logStream.end(); // Close the stream
  131. logStream = null;
  132. }
  133. // Determine the rotated log file name
  134. const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  135. const rotatedLogFile = `${logFile}.${timestamp}`;
  136. // Rename the current log file
  137. fs.renameSync(logFile, rotatedLogFile);
  138. // Compress the rotated log file
  139. try {
  140. const gzip = zlib.createGzip();
  141. const source = fs.createReadStream(rotatedLogFile);
  142. const destination = fs.createWriteStream(`${rotatedLogFile}.gz`);
  143. source.pipe(gzip).pipe(destination);
  144. source.on('end', () => {
  145. fs.unlinkSync(rotatedLogFile);
  146. });
  147. } catch (error) {
  148. console.error('Error compressing log file:', error);
  149. }
  150. // Manage total log size
  151. manageTotalLogSize();
  152. // Re-open the log stream
  153. logStream = fs.createWriteStream(logFile, { flags: 'a' });
  154. }
  155. }
  156. }
  157. // Function to manage total log size
  158. function manageTotalLogSize() {
  159. try {
  160. const files = fs.readdirSync(logDir);
  161. const logFiles = files
  162. .filter((file) => file.startsWith(`${serviceName}.log`))
  163. .map((file) => {
  164. const filePath = join(logDir, file);
  165. const stats = fs.lstatSync(filePath);
  166. return {
  167. name: file,
  168. path: filePath,
  169. time: stats.mtime.getTime(),
  170. size: stats.size,
  171. };
  172. });
  173. logFiles.sort((a, b) => b.time - a.time); // Sort by most recent
  174. // Calculate total size
  175. let totalSize = logFiles.reduce((acc, file) => acc + file.size, 0);
  176. // Delete oldest logs until total size is under maxTotalLogSize
  177. for (let i = logFiles.length - 1; i >= 0 && totalSize > maxTotalLogSize; i--) {
  178. try {
  179. fs.unlinkSync(logFiles[i].path);
  180. totalSize -= logFiles[i].size;
  181. } catch (error) {
  182. console.error('Error deleting log file:', error);
  183. }
  184. }
  185. } catch (err) {
  186. console.error('Error managing total log size:', err);
  187. }
  188. }
  189. // Daemon function
  190. function daemon() {
  191. // Create the logger
  192. const logger = createLogger();
  193. logger.info('Daemon started...');
  194. // Run the user-provided runner function
  195. let cleanupFunction: (() => void) | void;
  196. try {
  197. cleanupFunction = runner({ logger });
  198. } catch (err) {
  199. logger.error('Error in runner function:', err);
  200. process.exit(1);
  201. }
  202. // Signal handling
  203. function cleanUp() {
  204. logger.info('Daemon is stopping...');
  205. try {
  206. if (cleanupFunction) {
  207. cleanupFunction();
  208. }
  209. } catch (err) {
  210. logger.error('Error in cleanup function:', err);
  211. }
  212. closeLogger();
  213. try {
  214. fs.unlinkSync(lockFile);
  215. } catch {
  216. // Ignore errors
  217. }
  218. process.exit(0);
  219. }
  220. process.on('SIGTERM', cleanUp);
  221. process.on('SIGINT', cleanUp);
  222. process.on('SIGQUIT', cleanUp);
  223. process.on('uncaughtException', (err) => {
  224. logger.error('Uncaught exception:', err);
  225. cleanUp();
  226. });
  227. // Keep the process running
  228. process.stdin.resume();
  229. }
  230. // Install function
  231. function install() {
  232. console.log(`Installing ${serviceName}...`);
  233. if (!hasSystemd()) {
  234. console.error('Systemd is not available on this system.');
  235. process.exit(1);
  236. }
  237. const serviceConfigContent = `
  238. [Unit]
  239. Description=${serviceDescription}
  240. After=network.target
  241. [Service]
  242. ExecStart=${selfFile} daemon
  243. Environment="NODE_ENV=production" "_=${selfFile}"
  244. WorkingDirectory=${selfDir}
  245. User=root
  246. Restart=always
  247. KillSignal=SIGTERM
  248. TimeoutStopSec=5
  249. SyslogIdentifier=${serviceName}
  250. [Install]
  251. WantedBy=multi-user.target
  252. `;
  253. try {
  254. fs.writeFileSync(`/tmp/${serviceName}.service`, serviceConfigContent);
  255. execSync(`sudo mv /tmp/${serviceName}.service ${serviceConfig}`, {
  256. stdio: 'inherit',
  257. });
  258. execSync('sudo systemctl daemon-reload', { stdio: 'inherit' });
  259. execSync(`sudo systemctl enable ${serviceName}`, { stdio: 'inherit' });
  260. start();
  261. } catch (error) {
  262. console.error('Error during installation:', error);
  263. process.exit(1);
  264. }
  265. }
  266. // Uninstall function
  267. function uninstall() {
  268. console.log(`Uninstalling ${serviceName}...`);
  269. if (!hasSystemdService()) {
  270. console.error('Service is not installed via systemd.');
  271. process.exit(1);
  272. }
  273. try {
  274. execSync(`sudo systemctl stop ${serviceName}`, { stdio: 'inherit' });
  275. execSync(`sudo systemctl disable ${serviceName}`, { stdio: 'inherit' });
  276. execSync(`sudo rm ${serviceConfig}`, { stdio: 'inherit' });
  277. execSync('sudo systemctl daemon-reload', { stdio: 'inherit' });
  278. } catch (error) {
  279. console.error('Error during uninstallation:', error);
  280. process.exit(1);
  281. }
  282. }
  283. // Start function
  284. function start() {
  285. if (hasSystemdService()) {
  286. console.log(`Starting ${serviceName} via systemd...`);
  287. try {
  288. execSync(`sudo systemctl start ${serviceName}`, { stdio: 'inherit' });
  289. } catch (error) {
  290. console.error('Error starting service via systemd:', error);
  291. process.exit(1);
  292. }
  293. } else {
  294. console.log(`Starting ${serviceName} manually...`);
  295. if (isRunning()) {
  296. console.log(`${serviceName} is already running.`);
  297. process.exit(0);
  298. }
  299. const child = spawn(selfFile, ['daemon'], {
  300. detached: true,
  301. cwd: selfDir,
  302. stdio: ['ignore', 'ignore', 'ignore'],
  303. });
  304. if (!child.pid) {
  305. console.error('Failed to start the service.');
  306. process.exit(1);
  307. }
  308. fs.writeFileSync(lockFile, child.pid.toString());
  309. console.log(`${serviceName} started with PID ${child.pid}`);
  310. child.unref();
  311. }
  312. }
  313. // Stop function
  314. function stop() {
  315. if (hasSystemdService()) {
  316. console.log(`Stopping ${serviceName} via systemd...`);
  317. try {
  318. execSync(`sudo systemctl stop ${serviceName}`, { stdio: 'inherit' });
  319. } catch (error) {
  320. console.error('Error stopping service via systemd:', error);
  321. process.exit(1);
  322. }
  323. } else {
  324. console.log(`Stopping ${serviceName} manually...`);
  325. if (!fs.existsSync(lockFile)) {
  326. console.log(`${serviceName} is not running.`);
  327. process.exit(0);
  328. }
  329. const pidStr = fs.readFileSync(lockFile, 'utf-8');
  330. const pid = parseInt(pidStr, 10);
  331. try {
  332. process.kill(pid, 'SIGTERM');
  333. console.log(`Stopped ${serviceName} with PID ${pid}.`);
  334. } catch (err) {
  335. console.error(`Failed to stop ${serviceName}:`, err);
  336. }
  337. try {
  338. fs.unlinkSync(lockFile);
  339. } catch {
  340. // Ignore errors
  341. }
  342. }
  343. }
  344. // Restart function
  345. function restart() {
  346. if (hasSystemdService()) {
  347. console.log(`Restarting ${serviceName} via systemd...`);
  348. try {
  349. execSync(`sudo systemctl restart ${serviceName}`, { stdio: 'inherit' });
  350. } catch (error) {
  351. console.error('Error restarting service via systemd:', error);
  352. process.exit(1);
  353. }
  354. } else {
  355. console.log(`Restarting ${serviceName} manually...`);
  356. stop();
  357. // Give some time for the process to stop
  358. setTimeout(() => {
  359. start();
  360. }, 1000);
  361. }
  362. }
  363. // Status function
  364. function status() {
  365. if (hasSystemdService()) {
  366. try {
  367. execSync(`systemctl status ${serviceName}`, { stdio: 'inherit' });
  368. } catch {
  369. console.log(`${serviceName} is not running via systemd.`);
  370. }
  371. } else {
  372. if (isRunning()) {
  373. const pid = fs.readFileSync(lockFile, 'utf-8');
  374. console.log(`${serviceName} is running with PID ${pid}.`);
  375. } else {
  376. console.log(`${serviceName} is not running.`);
  377. }
  378. }
  379. }
  380. // Check if the process is running
  381. function isRunning() {
  382. try {
  383. const pidStr = fs.readFileSync(lockFile, 'utf-8');
  384. const pid = parseInt(pidStr, 10);
  385. process.kill(pid, 0);
  386. return true;
  387. } catch {
  388. return false;
  389. }
  390. }
  391. // Function to parse command-line options
  392. function parseLogOptions(args?: string): { follow: boolean; lines: number } {
  393. const options = { follow: false, lines: 10 }; // Default values
  394. const argsArray = args ? args.split(' ') : [];
  395. argsArray.forEach((arg, index) => {
  396. if (arg === '-f' || arg === '--follow') {
  397. options.follow = true;
  398. } else if (arg === '-n' || arg === '--lines') {
  399. const value = argsArray[index + 1];
  400. if (value && !isNaN(parseInt(value, 10))) {
  401. options.lines = parseInt(value, 10);
  402. }
  403. }
  404. });
  405. return options;
  406. }
  407. // Custom log viewer function
  408. function logs(args?: string) {
  409. const options = parseLogOptions(args);
  410. if (hasSystemdService()) {
  411. try {
  412. execSync(`journalctl -u ${serviceName} ${args || ''}`, {
  413. stdio: 'inherit',
  414. });
  415. } catch (error) {
  416. console.error('Error fetching logs via systemd:', error);
  417. process.exit(1);
  418. }
  419. } else {
  420. console.log(`Fetching logs manually...`);
  421. // Get all log files (including compressed ones)
  422. try {
  423. const files = fs.readdirSync(logDir);
  424. const logFiles = files
  425. .filter((file) => file.startsWith(`${serviceName}.log`))
  426. .map((file) => {
  427. const filePath = join(logDir, file);
  428. const stats = fs.lstatSync(filePath);
  429. return {
  430. name: file,
  431. path: filePath,
  432. time: stats.mtime.getTime(),
  433. };
  434. });
  435. logFiles.sort((a, b) => a.time - b.time); // Sort by oldest first
  436. if (logFiles.length > 0) {
  437. if (options.follow) {
  438. // Real-time log following
  439. followLogs();
  440. } else {
  441. // Display historical logs
  442. displayLogs(logFiles, options.lines);
  443. }
  444. } else {
  445. console.log('No logs found.');
  446. }
  447. } catch (error) {
  448. console.error('Error fetching logs:', error);
  449. }
  450. }
  451. }
  452. // Function to display historical logs
  453. function displayLogs(logFiles: { path: string }[], lines: number) {
  454. const allLines: string[] = [];
  455. for (const file of logFiles) {
  456. let fileStream: Readable;
  457. if (file.path.endsWith('.gz')) {
  458. fileStream = fs.createReadStream(file.path).pipe(zlib.createGunzip());
  459. } else {
  460. fileStream = fs.createReadStream(file.path);
  461. }
  462. const rl = readline.createInterface({
  463. input: fileStream,
  464. crlfDelay: Infinity,
  465. });
  466. rl.on('line', (line) => {
  467. allLines.push(line);
  468. });
  469. rl.on('close', () => {
  470. // After reading all lines, display the last 'n' lines
  471. const start = Math.max(allLines.length - lines, 0);
  472. for (let i = start; i < allLines.length; i++) {
  473. console.log(allLines[i]);
  474. }
  475. });
  476. }
  477. }
  478. // Function to follow logs in real-time
  479. function followLogs() {
  480. let currentLogFile = logFile;
  481. let position = 0;
  482. const readNewData = () => {
  483. fs.stat(currentLogFile, (err, stats) => {
  484. if (err) {
  485. // File might not exist yet
  486. return;
  487. }
  488. if (stats.size < position) {
  489. // Log file was rotated
  490. position = 0;
  491. }
  492. const stream = fs.createReadStream(currentLogFile, {
  493. encoding: 'utf-8',
  494. flags: 'r',
  495. start: position,
  496. });
  497. stream.on('data', (chunk) => {
  498. process.stdout.write(chunk as string);
  499. position += Buffer.byteLength(chunk, 'utf-8');
  500. });
  501. stream.on('error', (err) => {
  502. console.error('Error reading log file:', err);
  503. });
  504. });
  505. };
  506. // Initial read to get any existing data
  507. readNewData();
  508. // Watch the current log file
  509. let watcher = fs.watch(currentLogFile, (eventType) => {
  510. if (eventType === 'change') {
  511. readNewData();
  512. } else if (eventType === 'rename') {
  513. // Log file was rotated
  514. watcher.close();
  515. position = 0;
  516. currentLogFile = logFile; // The new log file
  517. // Start watching the new log file
  518. watcher = fs.watch(currentLogFile, (eventType) => {
  519. if (eventType === 'change') {
  520. readNewData();
  521. }
  522. });
  523. }
  524. });
  525. // Keep the process running
  526. process.stdin.resume();
  527. }
  528. // Command-line interface
  529. const action = process.argv[2];
  530. const args = process.argv.slice(3).join(' ');
  531. switch (action) {
  532. case 'install':
  533. install();
  534. break;
  535. case 'uninstall':
  536. uninstall();
  537. break;
  538. case 'start':
  539. start();
  540. break;
  541. case 'stop':
  542. stop();
  543. break;
  544. case 'restart':
  545. restart();
  546. break;
  547. case 'status':
  548. status();
  549. break;
  550. case 'logs':
  551. logs(args);
  552. break;
  553. case 'daemon':
  554. daemon();
  555. break;
  556. default:
  557. console.log(
  558. `Usage: ${selfFilename} {install|uninstall|start|stop|restart|status|logs [args]}`
  559. );
  560. console.log(`Description: ${serviceDescription}`);
  561. }
  562. }