sphp
Version:
A snappy PHP execution module / middleware
771 lines (640 loc) • 28.8 kB
JavaScript
/*============================================================================*\
Snappy PHP Script Launcher
PHP script execution module for node
Maintain a pool of ready workers for fast responcetime.
Use as express middleware:
app.use(sphp.express(<Document root>));
or direct call
sphp.exec
To attach to websocket server:
ws.on('connection',sphp.websocket(<options>));
(c) Copyrights Paragi, Simon Riget 2013
License MIT
\*============================================================================*/
var fs = require('fs');
var path = require('path');
var child_process = require('child_process');
var url = require('url');
var moduleVersion = 'Snappy PHP ' + require(__dirname + path.sep + 'package.json').version;
// Define module object
var sphp = {};
module.exports = exports = sphp;
// Initialize
sphp.worker=[];
sphp.increaseTime = false;
/*============================================================================*\
Express Middleware to execute a PHP script.
app.use(sphp.express(<array of options> | <PHP document root>));
The script is expected to return a complete HTML response.
The response will be partitioned into segments (using callback) of type:
status
header
data (including stderr)
end
error
\*============================================================================*/
sphp.express = function (options) {
// Maintain backwards compatibility
if(typeof options !== 'undefined')
if(typeof options === 'object')
sphp.setOptions(options);
else
sphp.setOptions({'docRoot': options});
// Return middleware function
return function (request, response, next) {
// Check file extension
if (path.extname(request._parsedUrl.pathname).substring(1) !== 'php') {
next();
return 0;
}
// Launch script
sphp.exec(request, function (event, data, param) {
// console.log('----Receiving ',event,' With: ',data,':',param);
if (!response.finished) {
switch (event) {
case 'status':
response.status(data);
break;
case 'header':
if (response.headersSent) break;
response.setHeader(data, param);
// Handle redirect header
if (data.toLowerCase() === 'location') {
response.writeHead(302, {'Content-Type': 'text/plain'});
response.end('ok');
}
break;
case 'data':
response.write(data, 'utf-8');
break;
case 'end':
response.end();
break;
case 'error':
console.error(data);
response.write(data, 'utf-8');
break;
default:
console.error('PHP script unknown event: "%s"', event);
}
}
});
};
};
/*============================================================================*\
Execute PHP script
Start a PHP session, by deploying a prespawned worker.
Using the script php_worker.php as a launcher script, to set predefined globals
\*============================================================================*/
sphp.exec=function(request,callback){
var deployed=false;
var freeWorker=false;
// Initialize workers
if(sphp.worker.length < 1)
sphp.maintain();
// Check disabled for now. Is consistency really that important here? or is it
// ok that PHP version is unknown to the first instanses of PHP scripts?
if(false && typeof process.versions.php === 'undefined'){
console.log('PHP engine is not initialized yet. The request was droped');
return;
}
// Parse URL for websocket calls
if(typeof request._parsedUrl === 'undefined')
request._parsedUrl=url.parse(request.socket.upgradeReq.url);
if(process.stdout.isTTY)
console.log("Serving PHP page:", request._parsedUrl.pathname);
// Check that script exists
fs.exists(sphp.docRoot + request._parsedUrl.pathname, function(exists){
// Deploy worker
if(exists){
// See if there is a free worker
for(var i = sphp.worker.length-1; i >= 0 ; i--){
// Deploy worker
if(sphp.worker[i].proc.state=='ready') {
// Set state
sphp.worker[i].proc.state='running';
sphp.worker[i].proc.time=Date.now();
sphp.worker[i].proc.callback = callback;
//Transfer conInfo request informastion to stdin
sphp.worker[i].proc.conInfo = sphp._getConInfo(request);
// Attach response handlers
sphp._responseHandler(sphp.worker[i],callback)
// Release input to worker (Let it run)
sphp.worker[i].stdin.write(JSON.stringify(sphp.worker[i].proc.conInfo));
sphp.worker[i].stdin.end();
if(process.stdout.isTTY && false)
console.info("Deploying worker PID: ",sphp.worker[i].pid);
deployed=true;
break;
}
}
// Too busy
if(!deployed){
callback('status',503
, "Sorry, too busy right now. Please try again later");
callback('end');
}
// File not found
}else{
callback('status',404, "Sorry, unable to locate file: "
+sphp.docRoot + request._parsedUrl.pathname);
callback('end');
console.info("File not found (404): "
+sphp.docRoot + request._parsedUrl.pathname);
}
});
}
/*============================================================================*\
Websocket: Attach on connection event
Attach a "receive message" event handler
If its a php file, execute it
The options are the ones uset to setup express-session:
var expressSession = require('express-session');
var sessionStore = new expressSession.MemoryStore();
var server = app.listen(8080,'0.0.0.0');
var ws = new require('ws').Server({server: server});
var sessionOptions={
store: sessionStore
,secret:'yes :c)'
,resave:false
,saveUninitialized:false
,rolling: true
,name: 'SID'
}
app.use(expressSession(sessionOptions));
ws.on('connection',sphp.websocket(sessionOptions));
options: store and name must be set.
\*============================================================================*/
sphp.websocket = function (opt){
var sessionCookieRegExp = new RegExp('(^|;)\\s*' + opt.name + '\\s*=\\s*([^;]+)');
return function(socket,IncomingMessage) {
//console.info("Client connected");
if(typeof socket.upgradeReq == 'undefined') // WS 3.0 fix
socket.upgradeReq = IncomingMessage;
// Handler for incomming messages
socket.on('message', function(msg){
var sid;
var parts;
//console.info("Received ws message: ",request.body);
// Create a pseudo request record
var request={
socket: socket
,body: msg.toString()
};
// Parse POST body as JSON to PHP
//socket.upgradeReq.headers['Content-Type']="application/json";
//console.log("WS Headers: ",socket.upgradeReq.headers);
// Find session cookie content, by name
parts = decodeURI(socket.upgradeReq.headers.cookie).match(sessionCookieRegExp);
//console.log("ws session parts: ",parts);
if(parts){
request.sessionID = parts[0].split(/[=.]/)[1];
// SID is serialised. Use value between s: and . as index (SID)
if(request.sessionID.substr(0,2) == 's:')
request.sessionID=request.sessionID.substr(2);
// Find session. Use value between s: and . as index (SID)
opt.store.get(request.sessionID,function(error,data){
if(data) request.session=data;
// Execute php script
sphp.exec(request,function(event,data){
// Handle returned data
if(event=='data' && request.socket.upgradeReq.socket.writable)
request.socket.send(data);
//console.log("Sending:",event,data);
});
},request);
// Execute PHP without session
}else sphp.exec(request,function(event,data){
// Handle returned data
if(event=='data' && request.socket.upgradeReq.socket.writable)
request.socket.send(data);
//console.log("Sending:",event,data);
});
});
}
}
/*============================================================================*\
Set options / configure sphp
set one or more options.
Options array of named key values pairs.
sphp.setOptions(options);
All options has been set to a defauls value
\*============================================================================*/
sphp.setOptions = function(option, callback){
if(typeof option !== 'object') return;
// Configure/overwrite option if any
sphp.docRoot = option.docRoot || sphp.docRoot;
sphp.minSpareWorkers = option.minSpareWorkers || sphp.minSpareWorkers;
sphp.maxWorkers = option.maxWorkers || sphp.maxWorkers;
sphp.stepDowntime = option.stepDowntime || sphp.stepDowntime;
sphp.overwriteWSPath = option.overwriteWSPaths || sphp.overwriteWSPath;
sphp.preLoadScript = option.preLoadScript || sphp.preLoadScript;
sphp.superglobals = option.superglobals || sphp.superglobals;
sphp.workerScript = option.workerScript || sphp.workerScript;
sphp.cgiEngine = option.cgiEngine || sphp.cgiEngine;
var list = [];
for(var i in sphp)
if(typeof sphp[i] !=='object' && typeof sphp[i] !=='function')
list[i] = sphp[i];
// console.log("Sphp options: ",list);
// Get info on PHP engine. Throw fatal error if it fails.
if(typeof option.cgiEngine === 'string'){
var child = child_process.spawn(option.cgiEngine, ['-v']);
child.resp = '';
child.stdout.on('data', function (buffer) {
child.resp += buffer.toString();
});
child.stderr.on('data', function (buffer) {
child.resp += buffer.toString();
});
child.on('close', function () {
process.versions.php = child.resp.split('\n')[0];
if(process.versions.php.length <1)
throw new Error("PHP engine '" +sphp.cgiEngine + "' failed to start.");
console.log("PHP engine: " + process.versions.php);
if( typeof callback === 'function')
callback();
});
child.on('error', (error) => {
throw new Error('PHP engine failed to start (' + sphp.cgiEngine +')');
});
}
}
/*============================================================================*\
Maintain PHP workers
PHP workers are preforked and kept ready, to improve response time.
The number of workers are determined by the demand. When minSpareWorkers are not
met do to demand, it is increased for a time. When it has not been needed for
stepDownTime, it is decreased again.
MinSpareWorkers: When a worker is spend (has run a script) it is terminated. If
the number of spare workers are below minSpareWorkers, new workers are forked.
Allocating more workers, will only improve response time up to a point. When the
resources becomes depleted, the only option is to prioritise and queue the
requests.
MaxWorkers: the number of workers that will never be exceed. Instead, the
request will be queued for maxWait time. If it expires the request are rejected.
Global variables are transfered via stdin, rather than enviroment variables, in
order to mimic the settings of Apache mod_php. That requires the pressens of a
php script that populates GLOBALS with data from stdin.
stdin is used to hold the process, until needed.
The list of workers are ordered with the oldest last, so that length reflects
the actual number of workers (Using add=>unshift delete=>splice)
Worker array objects layout:
state: enum ready, running, spend
time: of last state change
proc: handle to spawned process
cminSpareWorkers: current dynamic minimum of spare workers
increaseTime: Time that i changed
\*============================================================================*/
sphp.maintain = function(){
var spares = 0, workers = 0;
if(typeof sphp.cminSpareWorkers === 'undefined')
sphp.cminSpareWorkers = sphp.minSpareWorkers;
// Count free workers
for(var i in sphp.worker){
if(sphp.worker[i].proc.state == 'ready') spares++
if(sphp.worker[i].proc.state == 'dead')
sphp.worker.splice(i,1);
else
workers++;
}
if(sphp.cminSpareWorkers < sphp.minSpareWorkers)
sphp.cminSpareWorkers = sphp.minSpareWorkers;
// increase number of workers
if(spares<1 && workers < sphp.maxWorkers){
if(sphp.increaseTime) sphp.cminSpareWorkers++;
sphp.increaseTime = Date.now();
// Decrease number of workers
}else if(Date.now() - sphp.increaseTime>sphp.stepDowntime * 1000) {
if(sphp.cminSpareWorkers > sphp.minSpareWorkers) sphp.cminSpareWorkers--;
sphp.increaseTime = Date.now();
}
// Start spare workers
var option = {cwd: sphp.docRoot, env: process.env};
if(sphp.preLoadScript)
option.env.preload = sphp.docRoot + path.sep + sphp.preLoadScript;
for(; spares < sphp.cminSpareWorkers && workers < sphp.maxWorkers; spares++){
// Start child process and Append worker to array
sphp.worker.unshift(
child_process.spawn(
sphp.cgiEngine
,[sphp.workerScript]
,option
)
);
// Attach end of process event
sphp.worker[0].on('exit' , handleExit );
sphp.worker[0].on('close', handleExit );
sphp.worker[0].on('error', handleExit );
// Some process settings
sphp.worker[0].stderr.setEncoding('utf-8');
sphp.worker[0].stdout.setEncoding('utf-8');
sphp.worker[0].stdout.parent = sphp.worker[0];
sphp.worker[0].stderr.parent = sphp.worker[0];
sphp.worker[0].proc = {
state: 'ready'
,time: Date.now()
,outBuffer: ''
,errorBuffer: ''
}
if(!sphp.worker[0].pid) return;
// Make temporary listners for output (Errors)
sphp.worker[0].stdout.on('data', function(data) {
if(sphp.worker[0].proc.outBuffer.length < 4096)
sphp.worker[0].proc.outBuffer += data.toString();
});
sphp.worker[0].stderr.on('data', function(data) {
if(this.parent.proc.errorBuffer.length < 4096)
this.parent.proc.errorBuffer += data.toString();
});
workers++;
}
function handleExit(report) {
if(report && (typeof this.proc === 'undefined' || this.proc.state=='ready')){
//console.log("Exit handler:", report);
var str = "Failed to start PHP worker."
str += "\n PHP engine: " + sphp.cgiEngine;
str += "\n PHP engine version: " + process.versions.php;
str += "\n Worker script: " + sphp.workerScript;
//str += "\n Worker PID: "+worker.pid;
str+="\n Error: " + report;
if(this.proc.errorBuffer.length || this.proc.outBuffer.length){
str += "\n Script error message: "
str += "\n" + this.proc.outBuffer
str += "\n" + this.proc.errorBuffer;
}
this.proc.state="dead";
throw new Error(str);
}
if(this.proc.state!='dead')
process.nextTick(sphp.maintain);
}
// report on workers
if(process.stdout.isTTY && false){
console.info(("=").repeat(80));
console.info(
"PHP Workers spares:"
,spares
," min:"
,sphp.cminSpareWorkers
," Max:"
,sphp.maxWorkers
);
workers=0; spares=0;
for(var i in sphp.worker){
workers++;
console.info(i,"PID:",sphp.worker[i].pid," State:",sphp.worker[i].proc.state
," age:",+(Date.now()-sphp.worker[i].proc.time)/1000+" Seconds");
// Find free workers
if(sphp.worker[i].state=='ready') spares++
}
console.info(("=").repeat(80));
}
}
/*============================================================================*\
Handle output from the spawned process
request body part and other information are parsed through stdin, to the php
process. body including multipart are interpreted by the server, before parsing
it to the cgi.
Node provides for the uploaded files to be stored. they only need to be renamed
and information passed.
on reveiving data on stdid, all input is treated as headers, until end of
header section is send (double windows end of line: \n\r\n\r)
Data are received in multi part blocks, with no regard to the content.
eg. a data block might contain both headers, [end of header] and body
The receiving callback function must have data separated in:
status, header, data, error and end request.
Status 200 OK is assumed, if not set.
Note: if header contains a redirect (Location) the status must be set accordingly
Quirks:
1. php-cgi might send error text formatted in HTML before the headers
Fix: 1. error messages are stores until headers are send.
2. a default header of Content-type: text/html (overwritten if other))
2. php-cgi might send a header in one block and the line ending in another
Fix: buffer all headers until end of header section are received
3. the phpinfo() function requests pseudo pages for logo images.
for strange 404 see http://woozle.org/~neale/papers/php-cgi.html
\*============================================================================*/
sphp._responseHandler= function (worker,callback){
worker.proc.outBuffer='';
worker.proc.errorBuffer='';
worker.proc.headersSent = false;
worker.proc.headers='';
// Remove listners for workers in idle state
worker.stdout.removeAllListeners('data');
worker.stderr.removeAllListeners('data');
worker.removeAllListeners('error');
worker.removeAllListeners('exit');
worker.removeAllListeners('close');
// Catch output from script and send it to client
worker.stdout.on('data', function(data){
var worker = this.parent;
var redirect = false;
if(worker.proc.state != 'running') return;
if(!worker.proc.headersSent){
// Store headers until a end of header is received (\r\n\r\n)
worker.proc.headers += data.toString();
// Pre-process headers: divide headers into lines and separate body data
var eoh = worker.proc.headers.indexOf('\r\n\r\n');
var eohLen = 4;
if(eoh <= 0){
eoh = worker.proc.headers.indexOf('\n\n');
eohLen = 2;
}
if(eoh >= 0){
var line = worker.proc.headers.substr(0,eoh).split('\n');
var div;
for(var i in line){
// Split header line into key, value pair
div = line[i].indexOf(":");
if(div>0){
var key = line[i].substr(0,div);
var value = line[i].substr(div+2).replace("\r","");
// console.log("Sending header 1:",key,":",value);
callback('header',key,value);
}
}
worker.proc.headersSent = true;
// Handle redirect location header
// Send body part if any
if(worker.proc.headers.length>eoh+eohLen){
callback('data',worker.proc.headers.substr(eoh+eohLen));
}
}
// Body
}else{;
callback('data',data.toString());
}
});
// Error. Catch standard error output from script (but don't send it until the end)
worker.stderr.on('data', (function(worker,callback){
return function (data) {
if(worker.proc.errorBuffer.length < 4096)
worker.proc.errorBuffer += data.toString();
};
})(worker,callback));
worker.stdout.on('close', (function(worker,callback){
return function () { endWithGrace(worker,callback); };
})(worker,callback));
worker.stderr.on('close', (function(worker,callback){
return function () { endWithGrace(worker,callback); };
})(worker,callback));
worker.on('exit', (function(worker,callback){
return function () { endWithGrace(worker,callback); };
})(worker,callback));
worker.on('error', (function(worker,callback){
return function () { endWithGrace(worker,callback); };
})(worker,callback));
function endWithGrace(worker,callback){
if(worker.proc.state == 'running'){
worker.proc.state = 'dead';
if(!worker.proc.headersSent){
callback('header','Content-type','text/html'); // Fix 1
var eoh = worker.proc.headers.indexOf('\r\n\r\n');
if(eoh >= 0 && worker.proc.headers.length > eoh+4)
callback('data',worker.proc.headers.substr(eoh+4));
}
if(worker.proc.outBuffer.length) callback('data',worker.proc.outBuffer);
if(worker.proc.errorBuffer.length) callback('error',worker.proc.errorBuffer);
callback('end');
process.nextTick(sphp.maintain);
}
}
}
/*============================================================================*\
Compose a connection information record on client request
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ href │
├──────────┬──┬─────────────────────┬─────────────────────┬───────────────────────────┬───────┤
│ protocol │ │ auth │ host │ path │ hash │
│ │ │ ├──────────────┬──────┼──────────┬────────────────┤ │
│ │ │ │ hostname │ port │ pathname │ search │ │
│ │ │ │ │ │ ├─┬──────────────┤ │
│ │ │ │ │ │ │ │ query │ │
" https: // user : pass @ sub.host.com : 8080 /p/a/t/h ? query=string #hash "
│ │ │ │ │ hostname │ port │ │ │ │
│ │ │ │ ├──────────────┴──────┤ │ │ │
│ protocol │ │ username │ password │ host │ │ │ │
├──────────┴──┼──────────┴──────────┼─────────────────────┤ │ │ │
│ origin │ │ origin │ pathname │ search │ hash │
├─────────────┴─────────────────────┴─────────────────────┴──────────┴────────────────┴───────┤
│ URI │
├─────────────────────────────────────────────────────────┬───────────────────────────┬───────┤
│ │ URL │ │
└─────────────────────────────────────────────────────────┴───────────────────────────┴───────┘
REMOTE_PORT = socket.remotePort
REMOTE_ADDR = socket.remoteAddress
DOCUMENT_ROOT = File system full path to root of hosted files.
SCRIPT_NAME = pathname: path relative to document root, with filename and extention
PHP_SELF = SCRIPT_NAME;
SCRIPT_FILENAME = DOCUMENT_ROOT + SCRIPT_NAME
SERVER_HOST = host (with port)
SERVER_NAME = hostname (SERVER_HOST without port)
\*============================================================================*/
sphp._getConInfo=function(request){
// Copy predefined super globals
var conInfo = JSON.parse(JSON.stringify(sphp.superglobals));
var extReq;
/*==========================================================================*\
Websocket request
\*==========================================================================*/
if(typeof request.socket == 'object'
&& typeof request.socket.upgradeReq != 'undefined'
&& typeof request.socket.upgradeReq.headers != 'undefined'){
extReq = request.socket.upgradeReq;
conInfo._SERVER.REMOTE_PORT = request.socket._socket.remotePort || '';
conInfo._SERVER.REMOTE_ADDR = request.socket._socket.remoteAddress || '';
conInfo._SERVER.REQUEST_METHOD = 'websocket';
conInfo._GET = url.parse(request.socket.upgradeReq.url, true).query;
/*==========================================================================*\
basic HTTP request
\*==========================================================================*/
}else{
extReq = request;
if(typeof request.client == 'object'){
conInfo._SERVER.REMOTE_ADDR = request.client.remoteAddress || '';
conInfo._SERVER.REMOTE_PORT = request.client.remotePort || '';
}
conInfo._SERVER.REQUEST_METHOD = request.method || '';
conInfo._GET = request.query || {};
conInfo._FILES = {};
for(var f in request.files){
conInfo._FILES[f]={};
conInfo._FILES[f].name=request.files[f].name;
conInfo._FILES[f].size=request.files[f].size;
conInfo._FILES[f].tmp_name=request.files[f].path;
conInfo._FILES[f].type=request.files[f].type;
}
}
/*==========================================================================*\
// Non method specifics
\*==========================================================================*/
conInfo._SERVER.SERVER_PROTOCOL =
extReq.httpVersion ? "HTTP/" + extReq.httpVersion : '';
conInfo._SERVER.DOCUMENT_ROOT = path.resolve(sphp.docRoot);
if(request._parsedUrl){
conInfo._SERVER.REQUEST_URI = request._parsedUrl.href;
conInfo._SERVER.QUERY_STRING = request._parsedUrl.query;
// Does this work in windows !?
conInfo._SERVER.SCRIPT_NAME = request._parsedUrl.pathname || '/';
if(conInfo._SERVER.SCRIPT_NAME.charAt(0) != '/')
conInfo._SERVER.SCRIPT_NAME = '/' + conInfo._SERVER.SCRIPT_NAME;
conInfo._SERVER.PHP_SELF = conInfo._SERVER.SCRIPT_NAME;
conInfo._SERVER.SCRIPT_FILENAME = conInfo._SERVER.DOCUMENT_ROOT
+ conInfo._SERVER.SCRIPT_NAME;
if(request._parsedUrl.host)
conInfo._SERVER.SERVER_HOST = request._parsedUrl.host;
}
if(typeof extReq.headers === 'object')
for(var key in extReq.headers)
conInfo._SERVER['HTTP_' + key.toUpperCase().replace('-','_')]
= extReq.headers[key];
if(typeof conInfo._SERVER.HTTP_REFERER !== 'undefined'){
var refererUrl = url.parse(conInfo._SERVER.HTTP_REFERER);
conInfo._SERVER.SERVER_PORT = refererUrl.port;
conInfo._SERVER.SERVER_ADDR = refererUrl.hostname;
if(typeof conInfo._SERVER.SERVER_NAME === 'undefined'
|| conInfo._SERVER.SERVER_NAME.length == 0)
conInfo._SERVER.SERVER_NAME = refererUrl.hostname;
}
if(typeof conInfo._SERVER.HTTP_COOKIE !== 'undefined'){
conInfo._SERVER.HTTP_COOKIE_PARSE_RAW = conInfo._SERVER.HTTP_COOKIE;
var line = conInfo._SERVER.HTTP_COOKIE_PARSE_RAW.split(';');
for(var i in line){
var cookie = line[i].split('=');
if(cookie.length >0)
conInfo._COOKIE[cookie[0].trim()] = cookie[1].trim();
}
}
if(typeof request.body !== 'object' && request.body)
try{
conInfo._POST = JSON.parse(request.body);
}catch(e){}
else
conInfo._POST = request.body || {};
conInfo._REQUEST = Object.assign({}, conInfo._GET, conInfo._POST, conInfo._COOKIE);
if(request.session)
conInfo._SERVER.SESSION = request.session;
return conInfo;
}
// Set defaults
sphp.setOptions({
docRoot: path.resolve("." + path.sep + 'public')
,minSpareWorkers: 10
,maxWorkers: 20
,stepDowntime: 360
,overwriteWSPath: null
,cgiEngine: 'php-cgi' + (/^win/.test(process.platform) ? '.exe' : '')
,workerScript: __dirname + path.sep + 'php_worker.php'
,superglobals: {
_POST: {},
_GET: {},
_FILES: {},
_SERVER: {
GATEWAY_INTERFACE: moduleVersion,
SERVER_SOFTWARE: 'PHP Appilation Server using Node.js and WS Websockets',
SERVER_NAME: 'localhost'
},
_COOKIE: {}
}
});