php-embed
Version:
Bidirectional interoperability between PHP and Node.js in a single process
306 lines (293 loc) • 10.3 kB
JavaScript
;
var binary = require('node-pre-gyp');
var path = require('path');
var bindingPath =
binary.find(path.resolve(path.join(__dirname, '..', 'package.json')));
var bindings = require(bindingPath);
bindings.setIniPath(path.join(__dirname, 'php.ini'));
bindings.setStartupFile(path.join(__dirname, 'startup.php'));
bindings.setExtensionDir(path.dirname(bindingPath));
var packageJson = require('../package.json');
var Promise = require('prfun');
var url = require('url');
var request = Promise.promisify(bindings.request);
// Hacky way to make strings safe for eval.
var addslashes = function(s) {
return "'" + s.replace(/[\'\\\x00]/g, function(c) {
if (c === '\x00') {
return '\'."\0".\'';
}
return '\\' + c;
}) + "'";
};
exports.PhpObject = bindings.PhpObject;
// We write 0-length buffers to the stream and attach a callback
// to implement "flush". However, not all streams actually
// support this -- in particular, HTTP streams will never fire
// the callback if the input is 0 length (boo!). So we'll wrap
// the passed-in stream and do a little bit of bookkeeping to
// ensure this always works right.
// While we're at it, we'll do some hand-holding of the
// HTTP header API as well, as well as support reading
// POST data from an input stream.
// NOTE that this is not actually a node.js "WritableStream" any more;
// this is just an internal interface we can pass over to the PHP side.
var StreamWrapper = function(inStream, outStream) {
this._initWrite(outStream);
this._initHeader(outStream);
this._initRead(inStream);
};
// WRITE interface
StreamWrapper.prototype._initWrite = function(outStream) {
this.stream = outStream;
this.flushed = true;
this.error = null;
this.callbacks = [];
this.stream.on('drain', this._onDrain.bind(this));
this.stream.on('error', this._onError.bind(this));
this.stream.on('close', this._onClose.bind(this));
this.stream.on('finish', this._onFinish.bind(this));
};
StreamWrapper.prototype.write = function(buffer, cb) {
if (this.error) {
if (cb) { setImmediate(function() { cb(this.error); }); }
return true;
}
var notBuffered = (buffer.length === 0) ? this.flushed :
this.stream.write(buffer);
if (notBuffered) {
this.flushed = true;
if (cb) { setImmediate(cb); }
} else {
this.flushed = false;
if (cb) { this.callbacks.push(cb); }
}
return notBuffered;
};
StreamWrapper.prototype._onDrain = function() {
this.flushed = true;
this.callbacks.forEach(function(f) { setImmediate(f); });
this.callbacks.length = 0;
};
StreamWrapper.prototype._onError = function(e) {
this.error = e;
this._onDrain();
};
StreamWrapper.prototype._onClose = function() {
this._onError(new Error('stream closed'));
};
StreamWrapper.prototype._onFinish = function() {
this._onError(new Error('stream finished'));
};
// HEADER interface
StreamWrapper.prototype._initHeader = function(outStream) {
this.supportsHeaders = (
typeof (this.stream.getHeader) === 'function' &&
typeof (this.stream.setHeader) === 'function'
);
};
StreamWrapper.prototype.sendHeader = function(headerBuf) {
if (!this.supportsHeaders) { return; }
if (headerBuf === null) { return; } // This indicates the "last header".
var header;
try {
// Headers are sent as Buffer from PHP to JS to avoid re-encoding
// in transit. But node.js wants strings, so we need to do
// some decoding. Technically headers are ISO-8859-1 encoded,
// with a strong recommendation to only use ASCII.
// See RFC 2616, https://tools.ietf.org/html/rfc7230#section-3.2.4
header = headerBuf.toString('ascii');
} catch (e) {
console.error('BAD HEADER ENCODING, SKIPPING:', headerBuf);
return;
}
var m = /^HTTP\/(\d+\.\d+) (\d+)( (.*))?$/.exec(header);
if (m) {
this.stream.statusCode = parseInt(m[2], 10);
if (m[4]) {
this.stream.statusMessage = m[4];
}
return;
}
m = /^([^: ]+): (.*)$/.exec(header);
if (m) {
// Some headers we are allowed to emit more than once;
// node.js wants an array of values in that case.
var name = m[1];
var newValue = m[2];
var old = this.stream.getHeader(name);
if (old) {
if (!Array.isArray(old)) { old = [old]; }
newValue = old.concat(newValue);
}
this.stream.setHeader(name, newValue);
return;
}
console.error('UNEXPECTED HEADER, SKIPPING:', header);
};
// READ interface
StreamWrapper.prototype._initRead = function(inStream) {
this.inputStream = inStream;
this.inputSize = 0;
this.inputResult = null;
this.inputComplete = null;
this.inputLeftover = null;
this.inputCallbacks = []; // Future read requests.
this.inputError = null;
this.inputEnd = false;
if (!this.inputStream) {
this.inputEnd = true;
} else {
this.inputStream.on('data', this._onInputData.bind(this, false));
this.inputStream.on('end', this._onInputEnd.bind(this));
this.inputStream.on('error', this._onInputError.bind(this));
this.inputStream.pause();
}
};
StreamWrapper.prototype.read = function(size, cb) {
var self = this;
var error = this.inputError;
if (this.inputResult !== null) {
// Read already in progress, queue for later.
this.inputCallbacks.push(function() { self.read(size, cb); });
return;
}
if (this.inputEnd || error) {
if (cb) { setImmediate(function() { cb(error, new Buffer(0)); }); }
return;
}
this.inputResult = new Buffer(size);
this.inputSize = 0;
this.inputComplete = cb;
if (this.inputLeftover || size === 0) {
this._onInputData(false, this.inputLeftover || new Buffer(0));
} else {
// Enable data events.
this.inputStream.resume();
}
};
StreamWrapper.prototype._onInputData = function(isEnd, buffer) {
this.inputStream.pause();
var remaining = (this.inputResult.length - this.inputSize);
var amt = Math.min(buffer.length, remaining);
buffer.copy(this.inputResult, this.inputSize, 0, amt);
this.inputSize += amt;
// Are we done with this input request?
if (this.inputSize === this.inputResult.length || isEnd) {
var cb = this.inputComplete; // Capture this for callback
var err = this.inputError; // Capture this for callback
var result = this.inputResult.slice(0, this.inputSize);
setImmediate(function() { cb(err, result); }); // Queue callback
this.inputResult = this.inputComplete = null;
this.inputEnd = isEnd;
// Are we done with this buffer?
this.inputLeftover = (amt < buffer.length) ? buffer.slice(amt) : null;
// Were there any more reads waiting?
if (this.inputCallbacks.length > 0) {
setImmediate(this.inputCallbacks.shift());
}
} else {
// Need more chunks!
this.inputLeftover = null;
this.inputStream.resume();
}
};
StreamWrapper.prototype._onInputEnd = function() {
if (this.inputResult) {
this._onInputData(true, new Buffer(0));
} else {
this.inputEnd = true;
}
while (this.inputCallbacks.length > 0) {
setImmediate(this.inputCallbacks.shift());
}
};
StreamWrapper.prototype._onInputError = function(e) {
this.inputError = e;
this._onInputEnd();
};
exports.request = function(options, cb) {
options = options || {};
var source = options.source;
if (options.file) {
source = 'require ' + addslashes(options.file) + ';';
}
var stream = new StreamWrapper(options.request,
options.stream || process.stdout);
var buildServerVars = function() {
var server = Object.create(null);
server.CONTEXT = options.context;
if (options.file) {
server.PHP_SELF = options.file;
server.SCRIPT_FILENAME = options.file;
}
if (options.request) {
var headers = options.request.headers || {};
Object.keys(headers).forEach(function(h) {
var hh = 'HTTP_' + h.toUpperCase().replace(/[^A-Z]/g, '_');
// The array case is very unusual here: it should basically
// only occur for Set-Cookie, which isn't going to be sent
// *to* PHP. But make sure we don't crash if it is.
if (Array.isArray(headers[h])) { return; }
server[hh] = headers[h];
});
server.PATH = process.env.PATH;
server.SERVER_SIGNATURE = '<address>' +
'<a href="' + packageJson.homepage + '">' + packageJson.name +
'</a>: ' + packageJson.description + '</address>';
server.SERVER_SOFTWARE =
packageJson.name + ' ' + packageJson.version;
server.SERVER_PROTOCOL =
'HTTP/' + options.request.httpVersion;
server.GATEWAY_INTERFACE =
'CGI/1.1';
server.REQUEST_SCHEME =
'http'; // XXX Is it possible to determine this from req object?
server.REQUEST_METHOD =
options.request.method;
server.REQUEST_URI =
options.request.url;
server.SERVER_ADMIN =
'webmaster@localhost'; // Bogus value: user can override.
var parsedUrl = url.parse(options.request.url);
server.QUERY_STRING =
(parsedUrl.search || '').replace(/^[?]/, '');
var socket = options.request.socket;
if (socket) {
server.REMOTE_ADDR = socket.remoteAddress;
server.REMOTE_PORT = socket.remotePort;
server.SERVER_ADDR = socket.localAddress;
server.SERVER_PORT = socket.localPort;
}
server.DOCUMENT_ROOT =
'/var/www'; // Bogus value: user can override.
}
if (options.serverInitFunc) {
options.serverInitFunc(server);
}
return server;
};
var args = options.args || [];
var serverVars = buildServerVars();
// This function initializes $_SERVER inside the PHP request.
var initServer = function(server, cb) {
// This loop deliberately doesn't use "hasOwnProperty" in order to
// allow serverVars to be inherited from an object with defaults.
for (var f in serverVars) {
server[f] = serverVars[f];
}
// This hook is for test purposes only.
if (options._testInitServer) {
options._testInitServer(server);
}
cb();
};
return request(source, stream, args, serverVars, initServer).tap(function() {
// Ensure the stream is flushed before promise is resolved.
return new Promise(function(resolve, reject) {
stream.write(new Buffer(0), function(e) {
if (e) { reject(e); } else { resolve(); }
});
});
}).nodify(cb);
};