Skip to content

Instantly share code, notes, and snippets.

@morungos
Last active August 27, 2021 23:49
Show Gist options
  • Save morungos/f468e00dfb20d63f6ea9300fdc92df43 to your computer and use it in GitHub Desktop.
Save morungos/f468e00dfb20d63f6ea9300fdc92df43 to your computer and use it in GitHub Desktop.
Run a proxy to a php-fpm server for testing php applications
const express = require('express');
const morgan = require('morgan');
const path = require('path');
const fastCgi = require('fastcgi-client');
const log4js = require("log4js");
const logger = log4js.getLogger();
logger.level = "debug";
const args = require('minimist')(process.argv.slice(2));
/**
* Script options:
* --fpm-host - the hostname, defaults to 127.0.0.1
* --fpm-port - the port, defaults to 9000
* --port - the port to serve on, defaults to 4000
* --root - the base directory
* --debug - enable debug mode
* --header=X=Y -- send an additional server value of X=Y
*/
const options = {
host: args['fpm-host'] || '127.0.0.1',
port: args['fpm-port'] || 9000,
documentRoot: path.resolve(args['root'] || '.'),
skipCheckServer: true,
debug: !! args['debug']
};
const additionalHeaders = {};
const headers = (typeof args['header'] === 'string' ? [args['header']] : args['header']) || [];
for(let header of headers) {
const [key, value] = header.split("=", 2);
additionalHeaders[key.trim()] = value.trim();
}
const fpm = new Promise((resolve, reject) => {
const loader = fastCgi(options);
loader.on('ready', () => resolve(loader));
loader.on('error', reject);
});
const phpFpm = async function (req, res) {
let params = { uri: req.url };
if (!params.uri || !params.uri.startsWith('/')) {
throw new Error('invalid uri');
}
if (options.rewrite) {
const rules = Array.isArray(options.rewrite)
? options.rewrite : [options.rewrite];
for (const rule of rules) {
const match = params.uri.match(rule.search || /.*/);
if (match) {
let result = rule.replace;
for (const index in match) {
const selector = new RegExp(`\\$${index}`, 'g');
result = result.replace(selector, match[index]);
}
params.outerUri = params.uri;
params.uri = result;
break;
}
}
}
if (params.uri.indexOf('?') !== -1) {
params.document = params.uri.split('?')[0];
params.query = params.uri
.slice(params.document.length + 1)
.replace(/\?/g, '&');
}
if (!params.script) {
params.script = path.posix.join(options.documentRoot, params.document || params.uri);
}
const headers = Object.assign({}, {
REQUEST_METHOD: req.method,
CONTENT_TYPE: req.headers['content-type'],
CONTENT_LENGTH: req.headers['content-length'],
CONTENT_DISPOSITION: req.headers['content-disposition'],
DOCUMENT_ROOT: options.documentRoot,
SCRIPT_FILENAME: params.script,
SCRIPT_NAME: params.script.split('/').pop(),
REQUEST_URI: params.outerUri || params.uri,
DOCUMENT_URI: params.document || params.uri,
QUERY_STRING: params.query,
REQUEST_SCHEME: req.protocol,
HTTPS: req.protocol === 'https' ? 'on' : void 0,
REMOTE_ADDR: req.connection.remoteAddress,
REMOTE_PORT: req.connection.remotePort,
SERVER_NAME: req.connection.domain,
SERVER_PROTOCOL: 'HTTP/1.1',
GATEWAY_INTERFACE: 'CGI/1.1',
SERVER_SOFTWARE: 'php-fpm for Node',
REDIRECT_STATUS: 200
}, additionalHeaders);
for (const header in headers) {
if (typeof headers[header] === 'undefined') { delete headers[header]; }
}
for (let header in req.headers) {
headers['HTTP_' + header.toUpperCase().replace(/-/g, '_')] = req.headers[header];
}
if (options.debug) {
console.log(headers);
}
const php = await fpm;
php.request(headers, function (err, request) {
if (err) { throw err; }
let buffer = null;
req.pipe(request.stdin);
request.stdout.on('data', function (data) {
if (buffer === false) {
res.write(data);
return;
} else if (buffer === null) {
buffer = data;
} else {
buffer = Buffer.concat([buffer, data]); // eslint-disable-line
}
const delimiter = buffer.indexOf("\r\n\r\n");
if (delimiter === -1) {
return;
}
// OK, now we can decode the headers
const head = buffer.toString('latin1', 0, delimiter);
buffer = buffer.slice(delimiter + 4);
const parseHead = head.split('\r\n').filter((_) => _);
const responseHeaders = {};
let statusCode = 200;
let statusMessage = '';
for (const item of parseHead) {
const pair = item.split(': ');
if (pair.length > 1 && pair[0] && pair[1]) {
if (pair[0] in responseHeaders) {
responseHeaders[pair[0]].push(pair[1]);
} else {
responseHeaders[pair[0]] = [ pair[1] ];
}
if (pair[0] === 'Status') {
const match = pair[1].match(/(\d+) (.*)/);
statusCode = parseInt(match[1]);
statusMessage = match[2];
}
}
}
res.writeHead(statusCode, statusMessage, responseHeaders);
res.write(buffer);
buffer = false;
});
request.stderr.on('data', function (data) {
const strings = data.toString('binary').split("PHP message: ");
for(let string of strings) {
string = string.trim();
if (string.length == 0) continue;
if (string.startsWith("DEBUG:")) {
logger.debug("FPM:", string);
} else if (string.startsWith("INFO") || string.startsWith("NOTICE")) {
logger.info("FPM:", string);
} else {
logger.error("FPM:", string);
}
}
});
request.stdout.on('end', function () {
if (buffer === null) {
return res.status(500).send("Incomplete response");
}
res.end();
return true;
});
});
};
const app = express();
const staticMiddleware = express.static(options.documentRoot, {});
app.use(morgan('dev'));
app.use(async function(req, res, next) {
if (req.url == '/') {
return res.redirect('/index.php');
}
const parsed = new URL(req.url, "http://example.com");
if (parsed.pathname.endsWith(".php")) {
return next();
}
return staticMiddleware(req, res, next);
});
app.use(phpFpm);
const port = args['port'] || 4000;
logger.info(`Starting listen on port: ${port}`);
logger.info(`Serving from: ${options.documentRoot}`);
app.listen(port);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment