import {NextFunction, Request, Response} from "express"; import {Agent, IncomingMessage} from "http"; import {ServingStrategy} from "./core/servingStrategy"; import {Utils} from './core/utils' export type NPAS_Object = { [key: string]: any } export type NPAS_Middleware = ( ((...input: any[]) => void) | [string, ((...input: any[]) => void)] )[] | ((...input: any[]) => void); export type NPAS_Options_Output = { port: boolean, startup: boolean, date: boolean, ip: boolean, reqTime: boolean, os: boolean, browser: boolean, device: boolean, statusCode: boolean, method: boolean }; export type NPAS_Options = { host: string, port: number, env: NPAS_Object, ini_set: NPAS_Object, theme: string, secureOrigin: boolean, root: string, ini_config: string, indexFile: string, middleware: NPAS_Middleware, middlewareAfter: NPAS_Middleware, router: string, bin: string, clustersSet: string | number, output: NPAS_Options_Output | boolean, phpPerCluster: number }; class nodePhpAwesomeServer { private server: any; discIssued: boolean = false; workersPID: any[] = []; phpPortsObject: { [key: string]: number[] } = {}; phpPorts: number[] = []; utils: Utils = new Utils(); pid: number = process.pid; constructor(options: NPAS_Options) { let ua = require('ua-parser2')(); let chalk = require('chalk'); let cluster = require('cluster'); let routerPkg = require('php-built-in-web-server-router'); let serve = new ServingStrategy(); const routerDefaultPath = routerPkg.lib; this.server = new (require('events'))(); options = Utils.createOptions(options); const clustersTotal = Utils.getClusters(options.clustersSet); let showOutput = typeof options.output === 'object' && !options.output.hasOwnProperty('length'); /** * EXPOSE Variables to PHP */ let Vars = { OPTIONS: JSON.stringify(options), WORKERS: clustersTotal, ENVIRONMENT: JSON.stringify(options.env), EXPRESS_PROTOCOL: 'http', EXPRESS_SECURE_ORIGIN: options.secureOrigin, EXPRESS_HOST: options.host, EXPRESS_PORT: options.port, EXPRESS_ADDRESS: Utils.getValidAddress(options.host, options.port), EXPRESS_URL: "http://" + Utils.getValidAddress(options.host, options.port), MASTER_PID: process.pid, ROOT_PACKAGE: Utils.root('..'), INI_CONFIG: options.ini_config, /** * These are declared in workers.js */ WORKER_ID: "", WORKER_PID: "", ROUTER: "", ROUTER_DEFAULT: routerDefaultPath }; let exposeVars = {}; for (let vars in Vars) { exposeVars[`NODE_NPAS_${vars}`] = Vars[vars]; process.env[`NODE_NPAS_${vars}`] = Vars[vars]; } process.env[`NODE_NPAS_EXPOSED_VARS`] = JSON.stringify(Object.keys(exposeVars)); if (typeof options.ini_config === 'string' && options.ini_config.length) { if (options.ini_config.substr(-4, 4) !== '.ini') { console.log(chalk.red.bold(`Your ini config doesn't have '.ini' extension: ${options.ini_config}`)); this.close(); } if (!require('fs').existsSync(options.ini_config)) { console.log(chalk.red.bold(`Your ini config path is invalid: ${options.ini_config}`)); this.close(); } } if (!require('fs').existsSync(options.root)) { console.log(chalk.red.bold(`Your root path is invalid: ${options.root}`)); this.close(); } else { cluster.setupMaster({ exec: Utils.root('core', 'workers.js') }); for (let i = 0; i < clustersTotal; i++) { cluster.fork(process.env); } } /** * Update Ports if workers died and got hired. */ cluster.on("fork", () => { this.phpPorts = this.reloadPHPports(); }); let firstTimeOnline = clustersTotal; /** * Messages Received from Workers */ cluster.on('message', (worker: any, message: string) => { let info = JSON.parse(message); /** * Workers are announcing their online status * We'll emit 'connect' when all workers are online. * * or * * A worker died and a new one was created */ if (info.message === 'php.server.online' && !this.discIssued) { this.phpPortsObject[info.worker.workerId] = info.worker.phpPorts; serve.updatePorts(this.reloadPHPports()); this.workersPID.push(+worker.process.pid); if (showOutput && options.output.hasOwnProperty('startup')) { console.log(chalk.cyan.bold(info.worker.text)); } firstTimeOnline--; if (firstTimeOnline === 0) { //if (showOutput && options.output.hasOwnProperty('startup')) { if (cluster.isMaster) { const {isFreePort} = require('node-port-check'); isFreePort(options.port, options.host) .then(([initialPort, host, isPortAvailable]: [number, string, boolean]) => { if (isPortAvailable) { options.port = initialPort; let express = require('express'); let cors = require('cors'); let request = require('request'); let router = express.Router(); let app = express(); let fs = require('fs'); let listenOnHOST = this.utils.getLocalAddress(); let koh = options.host; let bindGlobal = options.host === '0.0.0.0' || koh === listenOnHOST; let server_addr = bindGlobal ? listenOnHOST : koh; app.use(cors({credentials: true, origin: true})); /** * Add default favicon */ app.use((req: Request, res: Response, next: NextFunction) => { serve.updatePorts(this.phpPorts); const icons = [ "favicon.ico", "favicon.png", "apple-touch-icon-120x120-precomposed.png", "apple-touch-icon-precomposed.png", "apple-touch-icon.png" ]; const isIconRequested = icons.indexOf(req.url.substr(1)); if (isIconRequested > -1 && !fs.existsSync(Utils.root("..", options.root, icons[isIconRequested]))) { if (fs.existsSync(Utils.root('..', 'router', 'icons', icons[isIconRequested]))) { res.sendFile(Utils.root('..', 'router', 'icons', icons[isIconRequested])); } else { next(); } } else { next(); } }); /** * Add middleware from options */ if (typeof options.middleware === 'function') { router.use(options.middleware); } else { if (options.middleware instanceof Array) { if (typeof options.middleware[0] === 'string' && typeof options.middleware[1] === 'function' && options.middleware.length === 2) { router.use.apply(router, options.middleware); } else { options.middleware.forEach((middleware) => { if (middleware instanceof Array) { router.use.apply(router, middleware); } else { router.use(middleware); } }) } } } /** * Pipe all requests to workers * The loaded worker will send it further */ router.use((req: Request, res: Response, next: NextFunction) => { let [incomingHost, incomingPort] = req.headers['host'].toString().split(":"); req['serveJob'] = serve.activityRequest(req); req['serveJob']['requestOptions'] = { url: `${req.protocol}://127.0.0.1:${process.pid}${req.url}`, headers: { 'HOST': `${incomingHost}:${incomingPort}`, /** * NPAS_FIX_* - these values are used by PHP to adjust * the router logic */ 'NPAS_FIX_REMOTE_ADDR': req.connection.remoteAddress, 'NPAS_FIX_SERVER_NAME': incomingHost !== options.host ? incomingHost : 'localhost', 'NPAS_FIX_SERVER_ADDR': server_addr, 'NPAS_EXPRESS_HOSTNAME': incomingHost, 'NPAS_EXPRESS_HOSTNAME_PORT': incomingPort, 'NPAS_EXPRESS_TARGET_PORT': req['serveJob']['targetPort'], 'USER_AGENT': req.headers['user-agent'], }, followRedirect: false, time: true, jar: true, agent: new Agent({keepAlive: true, maxSockets: 1}), encoding: null, gzip: true, }; next(); }); router.use((req: Request, res: Response, next: NextFunction) => { let parserData = ua.parse(req.headers['user-agent']); let browser = parserData.ua.family; let os = parserData.os.family; let device = parserData.device.family; let outputData = { message: 'php.server.response', data: { hostPort: '', port: req['serveJob']['targetPort'], code: res.statusCode, url: req.url, ip: req.ip, xhr: req.xhr, method: req.method.toUpperCase(), browser: browser, os: os, device: device } }; /** * IncommingMessage * @type {NodeJS.ReadableStream} */ const safeStringify = (obj: any, indent = 2) => { let cache: any[] = []; const retVal = JSON.stringify( obj, (key, value) => typeof value === "object" && value !== null ? cache.includes(value) ? undefined // Duplicate reference found, discard key : cache.push(value) && value // Store value in our collection : value, indent ); cache = null; return retVal; }; req.pipe(request(req['serveJob']['requestOptions'], (err: Error, resp: IncomingMessage, body: Buffer) => { if (err || !resp) { res.set("Content-Type", "application/json"); let customMessages = []; customMessages.push("Error from master."); customMessages.push("Usually this kind of errors occurs from a corrupted output."); customMessages.push("Check the following headers to be correct: Content-Type, Content-Length."); let message = { npas: customMessages, requestOptions: req['serveJob']['requestOptions'], outputData: outputData, error: err.message }; return res.send(safeStringify(message)); //res.send(JSON.stringify(message)); } const sock: any = req.socket; const ipAndPort = sock._peername.address + ':' + sock._peername.port; res.status(resp.statusCode); res.statusMessage = resp.statusMessage; serve.activityResponse(resp, req['serveJob']); outputData.data['code'] = resp.statusCode; outputData.data['ip'] = ipAndPort; outputData.data['workerId'] = resp.headers['worker-id']; outputData.data['reqTime'] = (resp as any).timingPhases.total; //&& options.output.hasOwnProperty('startup') if (showOutput ) { nodePhpAwesomeServer.outputFormat(outputData, options); } let phpHeaders: NPAS_Object = {}; for (let headerName in resp.headers) { if (resp.headers.hasOwnProperty(headerName)) { phpHeaders[headerName] = resp.headers[headerName].toString().split(';').map((item: string) => item.trim()); res.setHeader(headerName, resp.headers[headerName]); if (headerName === 'content-type') { req.headers[headerName] = resp.headers[headerName]; } } } res.locals['phpHeaders'] = phpHeaders; res.locals['phpBody'] = body; next(); })); req.on('error', (err) => { console.log('error', err) }) req.on('clientError', (err) => { console.log('clientError', err) }) }); /** * Add middlewareAfter from options */ if (typeof options.middlewareAfter === 'function') { router.use(options.middlewareAfter); } else { if (options.middlewareAfter instanceof Array) { if (typeof options.middlewareAfter[0] === 'string' && typeof options.middlewareAfter[1] === 'function' && options.middlewareAfter.length === 2) { router.use.apply(router, options.middlewareAfter); } else { options.middlewareAfter.forEach((middleware) => { if (middleware instanceof Array) { router.use.apply(router, middleware); } else { router.use(middleware); } }) } } } router.use((req: Request, res: Response) => { res.end(res.locals.phpBody) }); app.set('trust proxy'); app.disable('x-powered-by'); app.use(router); app.listen(options.port, options.host, () => { serve.updatePorts(this.reloadPHPports()); let routerType: number = Utils.detectRouterType(options.router, routerDefaultPath); let routerTypeAlias: () => any = chalk.cyan.bold("default"); if (routerType === 2) { routerTypeAlias = chalk.yellow.bold("user-defined"); } if (routerType === 3) { routerTypeAlias = chalk.red.bold("default (fallback)"); } if (typeof options.ini_config === 'string' && options.ini_config.length) { console.log(chalk.cyan.bold('INI CONFIG:', options.ini_config)); } console.log(chalk.cyan.bold('DOCUMENT ROOT:', info.worker.docRoot)); console.log(chalk.cyan.bold('ROUTER:', routerType === 1 ? 'default' : routerTypeAlias + " | " + info.worker.router)); console.log(chalk.cyan.bold('LINK:', `http://localhost:${options.port}`)); if (bindGlobal) { console.log(chalk.cyan.bold('EXTERNAL:', `http://${listenOnHOST}:${options.port}`)); } this.server.emit('connect'); }) // app.on('error', (err: any)=>{ // console.log(err); // }) // app.on('clientError', (err: any)=>{ // console.log(err); // }) } else { console.log(`Port ${initialPort} is not available, last tried port was`, initialPort); } }) .catch((err: Error) => { console.log('free port error',err) }) // isFreePort(options.port, options.host, (availablePort: number, initialPort: number) => { // }); } //} } } /** * There was a problem with ExpressJS - check host:port availability */ if (info.message === 'kill.master') { setTimeout(() => { if (!this.discIssued) { console.log("Check your host and port:", info.err); this.close(); } }, 100); } }); /** * Create a new worker upon failure. */ cluster.on('exit', (worker: any) => { if (this.discIssued === false) { /** * Clean up phpPortsObject */ if (this.phpPortsObject.hasOwnProperty(worker.id)) { delete this.phpPortsObject[worker.id]; } console.log(chalk.red.bold(`Worker ${worker.id} died!`), chalk.yellow.bold(`Hiring a new one ...`)); /** * With a new cluster comes a new workers.js * Therefore a new 'php.server.online' message */ cluster.fork(process.env); } else { worker.destroy(); } }); } private reloadPHPports() { this.phpPorts = []; for (let item in this.phpPortsObject) { this.phpPortsObject[item].forEach((port: number) => { this.phpPorts.push(port); }) } return this.phpPorts; } on(event: string, callback: () => {}) { this.server.on(event, callback); return this; } close() { this.server.emit('close'); this.discIssued = true; setTimeout(() => { this.workersPID.forEach(worker => { console.log('killed', worker); process.kill(worker); }) }, 500); return this; }; private static outputFormat(info: { [key: string]: any }, options: { [key: string]: any }) { let chalk = require('chalk'); let dateFormat = require('dateformat'); let now = new Date(); let fullOutput: string | any[] = []; /** * Worker id and URL will always output */ let workerId = `[W${info.data.workerId}]`; let url = info.data.url; /** * Output Config Values */ let date = `[${dateFormat(now)} ${now.getFullYear()}]`; let ip = info.data.ip; let os = `[${info.data.os}]`; let browser = `[${info.data.browser}]`; let device = `[${info.data.device}]`; let statusCode = `[${info.data.code}]`; let method = `[${info.data.method}]${info.data.xhr ? `[XHR]` : ''}`; /** * Format port */ let port = info.data.port; let prefixPort = ""; for (let i = 0; i < (5 - (port.toString()).length); i++) { prefixPort += " "; } port = `[${prefixPort}${port}]`; /** * Format reqTime * @type {string} */ let reqTime = Math.round(info.data.reqTime).toString(); let timeSuffix = parseInt(reqTime) > 999 ? 's' : 'ms'; reqTime = (parseInt(reqTime) > 999 ? parseInt(reqTime) / 1000 : reqTime).toString(); reqTime = `[${reqTime}${timeSuffix}]`; if (typeof options.output === 'object' && !options.output.hasOwnProperty('length')) { fullOutput.push(workerId); if (options.output.port) { fullOutput.push(port); } if (options.output.date) { fullOutput.push(date); } if (options.output.ip) { fullOutput.push(ip); } if (options.output.os) { fullOutput.push(os); } if (options.output.browser) { fullOutput.push(browser); } if (options.output.device) { fullOutput.push(device); } if (options.output.statusCode && options.output.method) { fullOutput.push(`${statusCode}${method}`); } else { if (options.output.statusCode) { fullOutput.push(statusCode); } if (options.output.method) { fullOutput.push(method); } } if (options.output.reqTime) { fullOutput.push(reqTime); } fullOutput = `${fullOutput.join(' ')}: ${url}`; /** * Ok */ if (info.data.code === 200 || info.data.code === 201 || info.data.code === 204) { fullOutput = chalk.green.bold(fullOutput); } /** * Cached */ if (info.data.code === 304) { fullOutput = chalk.cyan.bold(fullOutput); } if (info.data.code >= 300 && info.data.code !== 304 && info.data.code < 400) { fullOutput = chalk.yellow.bold(fullOutput); } /** * Errors */ if (info.data.code >= 400) { fullOutput = chalk.red.bold(fullOutput); } console.log(fullOutput); } } } module.exports = (options: NPAS_Options) => { return new nodePhpAwesomeServer(options); };