const mysql = require('mysql2/promise'); const exec = require('child_process').exec; const fs = require('fs-extra'); const path = require('path'); const colors = require('colors'); const axios = require('axios'); const yml = require('require-yml'); const os = require("os"); const selectQuery = fs.readFileSync(`sql/selectQueued.sql`).toString(); const jobDataQuery = fs.readFileSync(`sql/jobData.sql`).toString(); const jobArgsQuery = fs.readFileSync(`sql/jobArgs.sql`).toString(); const printingQuery = fs.readFileSync(`sql/updatePrinting.sql`).toString(); const errorQuery = fs.readFileSync(`sql/updateError.sql`).toString(); const printedQuery = fs.readFileSync(`sql/updatePrinted.sql`).toString(); class PrintServer { async start() { let conf = yml(path.join(__dirname, 'config.yml')); const localConfFile = path.join(__dirname, 'config.local.yml'); if (fs.existsSync(localConfFile)) conf = Object.assign({}, conf, yml(localConfFile)); this.conf = conf; this.jobs = []; console.clear(); const decoration = '△▽'.repeat(10); const printnatura = colors.bgBlack.bold(' Print'.white + 'Natura '.green); console.log(`${decoration} ${printnatura} ${decoration}`); if (this.conf.debug) this.serverLog('log', 'Debug mode enabled'.yellow); if (this.conf.dryPrint) this.serverLog('log', 'Running in dry print mode, documents won\'t be printed'.yellow); if (this.conf.keepFile) this.serverLog('log', 'Keep file enabled, documents won\'t be deleted from disk'.yellow); await this.init(); this.rejectionHandler = (err, p) => this.onRejection(err, p); process.on('unhandledRejection', this.rejectionHandler); this.serverLog('log', 'Ready to print'.green); await this.poll(); } async stop() { this.serverLog('log', 'Bye ( ◕ ‿ ◕ )っ'.green); process.off('unhandledRejection', this.rejectionHandler); await this.end(); } async init() { const conf = this.conf; const api = this.api = axios.create({ baseURL: `${conf.salix.url}/api/` }); api.interceptors.request.use(config => { if (this.token) config.headers['Authorization'] = this.token return config; }); await this.getToken(); this.pool = await mysql.createPool(conf.db); } async end() { if (this.token) { await this.api.post(`Accounts/logout`); this.token = null; } if (this.pollTimeout) { clearTimeout(this.pollTimeout); this.pollTimeout = null; } await this.pool.end(); } async getToken() { const salix = this.conf.salix; let response = await this.api.post(`Accounts/login`, { user: salix.user, password: salix.password }); this.token = response.data.token; } serverLog(realm, message) { this.log(`Server`, realm, message); } log(classMsg, realm, message) { classMsg = `${classMsg}:`; switch(realm) { case 'debug': if (this.conf.debug) console.debug(classMsg, message.magenta); break; case 'error': console.error(classMsg, message.red); break; default: if (this.conf.log) console.log(classMsg, message); } } onRejection(err, p) { console.debug('unhandledRejection'); this.errorHandler(err); } errorHandler(err) { if (['ETIMEDOUT', 'ECONNRESET', 'PROTOCOL_CONNECTION_LOST'].includes(err.code)) { if (!this.dbDown) { this.dbDown = true; this.serverLog('error', `DB connection lost: ${err.message}`); } } else console.error(err); } async poll() { this.pollTimeout = null; let delay = this.conf.refreshRate; await this.getJobs(); if (this.dbDown) delay = this.conf.reconnectTimeout * 1000; this.pollTimeout = setTimeout(() => this.poll(), delay); } async getJobs() { if (this.polling) return; this.polling = true; const conf = this.conf; if (this.dbDown) { try { const conn = await this.pool.getConnection(); try { await conn.ping(); this.dbDown = false; this.serverLog('log', 'DB connection recovered'.green); } catch (err) { conn.release(); } } catch(e) {} } if (!this.dbDown) { try { let nJobs = 0; while (this.jobs.length < conf.concurrency) { const jobId = await this.getJob(); if (jobId) { nJobs++; this.jobs.push(jobId); const jobP = this.printJob(jobId); // XXX: Workaround for Promise unhandledRejection // https://stackoverflow.com/questions/67789309/why-do-i-get-an-unhandled-promise-rejection-with-await-promise-all jobP.catch(err => this.errorHandler(err)); } else break; } if (nJobs > 0) this.serverLog('debug', `${nJobs} jobs buffered`); } catch (err) { this.errorHandler(err); } } this.polling = false; } async getJob() { let jobId; const conn = await this.pool.getConnection(); try { await conn.beginTransaction(); try { const [[printJob]] = await conn.query(selectQuery); if (printJob) { jobId = printJob.id; await conn.query(printingQuery, [ 'printing', now(), null, null, this.conf.serverId || os.hostname, jobId ]); this.jobLog(jobId, 'debug', 'get: printing'); await conn.commit(); } else await conn.rollback(); } catch (err) { try { await conn.rollback(); } catch (e) {} if (jobId) this.jobLog(jobId, 'error', err.message); throw err; } } finally { conn.release(); } return jobId; } async printJob(jobId) { const conf = this.conf; let jobData; const args = {}; let tmpFilePath; let tmpFileCreated = false; let conn; try { conn = await this.pool.getConnection(); // Job data await conn.beginTransaction(); try { const [[data]] = await conn.query(jobDataQuery, jobId); jobData = data; const [res] = await conn.query(jobArgsQuery, jobId); for (const row of res) args[row.name] = row.value; } finally { await conn.rollback(); } // Path params const usedParams = new Set(); const methodPath = jobData.method.replace(/{\w+}/g, function(match) { const key = match.substr(1, match.length - 2); const value = args[key]; usedParams.add(key); return value !== undefined ? value : match; }); // URL params const params = {userFk: jobData.userFk}; for (const key in args) { if (!usedParams.has(key)) params[key] = args[key]; } const urlParams = new URLSearchParams(params); const url = `${methodPath}?${urlParams.toString()}`; this.jobLog(jobId, 'debug', `api: ${url}`); // Request let pdfData; for (let attempts = 0; !pdfData && attempts < this.conf.retryAttempts; attempts++) { try { const res = await this.api({ method: 'get', url, responseType: 'arraybuffer', headers: {'Accept': 'application/pdf'} }); pdfData = res.data; } catch (err) { if (err.name === 'AxiosError' && attempts < this.conf.retryAttempts - 1) { const res = err.response; switch(res.status) { case 401: // Unauthorized await this.getToken(); break; case 502 || 504: // Bad Gateway & Gateway Timeout await new Promise( resolve => setTimeout(resolve, this.conf.retryTimeout)); break; default: try { if (err.code === 'ERR_BAD_REQUEST') { const resMessage = JSON.parse(res.data).error.message; const resErr = new Error(`${err.message}: ${resMessage}`); resErr.stack = err.stack; throw resErr; } else throw err; } catch (err) { throw err; } } } else throw err; } } // Save PDF to disk const printer = jobData.printer; const tmpPath = conf.tmpDir; if (!fs.existsSync(tmpPath)) fs.mkdirSync(tmpPath) tmpFilePath = path.join(tmpPath, `job-${jobId}.pdf`); await fs.writeFile(tmpFilePath, pdfData, 'binary'); tmpFileCreated = true; // Print PDF const printCommand = `lp -d "${printer}" "${tmpFilePath}"`; this.jobLog(jobId, 'debug', `print: ${printCommand}`); try { if (!conf.dryPrint) await pExec(printCommand); } catch(err) { throw new Error(`Print error: ${err.message}`); } await conn.query(printedQuery, ['printed', now(), jobId]); this.jobLog(jobId, 'log', `${jobData.report}: '${printCommand}': GET ${url}`); } catch (err) { try { await conn.query(errorQuery, ['error', now(), err.message, jobId]); } catch (e) { this.jobLog(jobId, 'error', e.message); } this.jobLog(jobId, 'error', err.message); const jobErr = new Error(`(${jobId}) ${err.message}`); jobErr.stack = err.stack; throw jobErr; } finally { if (conn) conn.release(); if (!conf.keepFile) { try { const shouldDelete = tmpFileCreated || (tmpFilePath && await fs.pathExists(tmpFilePath)); if (shouldDelete) await fs.unlink(tmpFilePath); } catch (err) { this.jobLog(jobId, 'error', err.message); } } const index = this.jobs.indexOf(jobId); if (index !== -1) this.jobs.splice(index, 1); setTimeout(() => this.getJobs()); } } jobLog(jobId, realm, message) { this.log(`Job[${colors.yellow(jobId)}]`, realm, message); } } module.exports = PrintServer; function pExec(command) { return new Promise(function(resolve, reject) { exec(command, function(err, stdout, stderr) { if (err) return reject(err); resolve({stdout, stderr}) }); }); } function now() { return new Date().toISOString().slice(0, 19).replace('T', ' '); }