Compare commits

..

2 commits
main ... next

20 changed files with 8770 additions and 1003 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Build output
/dist/
# SVR.JS
/svrjs/
# Dependencies
node_modules
# ESLint cache
.eslintcache

2
.husky/commit-msg Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
npx --no -- commitlint --edit "$1"

2
.husky/pre-commit Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
npx lint-staged

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2024 SVR.JS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

29
README Normal file
View file

@ -0,0 +1,29 @@
This repository contains SVR.JS mod starter code and its build system
The mod will work for SVR.JS Nightly-GitNext.
Before doing anything, run "npm install".
To build SVR.JS mod, run "npm run build".
To check SVR.JS mod code for errors with ESLint, run "npm run lint".
To fix and beautify SVR.JS mod code with ESLint and Prettier, run "npm run lint:fix".
To perform unit tests with Jest, run "npm test".
To test the mod:
1. Clone the SVR.JS repository with "git clone https://git.svrjs.org/svrjs/svrjs.git" command.
2. Change the working directory to "svrjs" using "cd svrjs".
3. Build SVR.JS by first running "npm install" and then running "npm run build".
4. Copy the mod into mods directory in the dist directory using "cp ../dist/mod.js dist/mods" (GNU/Linux, Unix, BSD) or "copy ..\dist\mod.js dist\mods" (Windows).
5. Do the necessary mod configuration if the mod requires it.
6. Run SVR.JS by running "npm start".
7. Do some requests to the endpoints covered by the mod.
Structure:
- dist - contains the built SVR.JS mod
- src - contains SVR.JS mod source code
- index.js - entry point
- utils - utility functions
- tests - Jest unit tests
- utils - unit tests for utility functions
- esbuild.config.js - the build script
- eslint.config.js - ESLint configuration
- jest.config.js - Jest configuration
- modInfo.json - SVR.JS mod name and version

3
commitlint.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
extends: ["@commitlint/config-conventional"]
};

11
esbuild.config.js Normal file
View file

@ -0,0 +1,11 @@
const esbuild = require("esbuild");
esbuild.build({
entryPoints: ["src/index.js"],
bundle: true,
outfile: "dist/mod.js", // Mod output file
platform: "node",
target: "es2017",
}).catch((err) => {
throw err;
});

30
eslint.config.js Normal file
View file

@ -0,0 +1,30 @@
const globals = require("globals");
const pluginJs = require("@eslint/js");
const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended");
const jest = require("eslint-plugin-jest");
module.exports = [
{
files: ["**/*.js"],
languageOptions: {
sourceType: "commonjs"
}
},
{
files: ["tests/*.test.js", "tests/**/*.test.js"],
...jest.configs['flat/recommended'],
rules: {
...jest.configs['flat/recommended'].rules,
'jest/prefer-expect-assertions': 'off',
}
},
{
languageOptions: {
globals: {
...globals.node
}
}
},
pluginJs.configs.recommended,
eslintPluginPrettierRecommended
];

1002
index.js

File diff suppressed because it is too large Load diff

5
jest.config.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
verbose: true,
};

4
lint-staged.config.js Normal file
View file

@ -0,0 +1,4 @@
module.exports = {
"tests/**/*.js": "eslint --cache --fix",
"src/**/*.js": "eslint --cache --fix"
};

2
mod.info → modInfo.json Executable file → Normal file
View file

@ -1,4 +1,4 @@
{
"name": "GreenRhombus FastCGI client for SVR.JS",
"version": "Nightly-GitMain"
"version": "Nightly-GitNext"
}

7308
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

35
package.json Normal file
View file

@ -0,0 +1,35 @@
{
"name": "svrjs-mod-starter",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "node esbuild.config.js",
"cz": "cz",
"lint": "eslint --no-error-on-unmatched-pattern src/**/*.js src/*.js tests/**/*.test.js tests/**/*.js",
"lint:fix": "npm run lint -- --fix",
"prepare": "husky",
"test": "jest"
},
"devDependencies": {
"@commitlint/cli": "^19.6.1",
"@commitlint/config-conventional": "^19.6.0",
"@eslint/js": "^9.9.1",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"esbuild": "^0.23.1",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.8.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^15.9.0",
"husky": "^9.1.7",
"jest": "^29.7.0",
"lint-staged": "^15.2.11",
"prettier": "^3.3.3"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
}

15
prettier.config.js Normal file
View file

@ -0,0 +1,15 @@
// prettier.config.js, .prettierrc.js, prettier.config.cjs, or .prettierrc.cjs
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
trailingComma: "none",
tabWidth: 2,
semi: true,
singleQuote: false,
endOfLine: "lf"
};
module.exports = config;

542
src/index.js Normal file
View file

@ -0,0 +1,542 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const { FastCGIHandler } = require("./utils/fastCgi.js"); // FastCGI handler
const createCGIParser = require("./utils/cgiParser.js"); // CGI parser
const modInfo = require("../modInfo.json"); // SVR.JS mod information
let fastcgiConfigFromSeparateFile = {};
try {
fastcgiConfigFromSeparateFile = JSON.parse(
fs.readFileSync(process.dirname + "/greenrhombus-config.json")
);
// eslint-disable-next-line
} catch (ex) {
// Use defaults
}
let scriptExtsFromSeparateFile = [];
try {
scriptExtsFromSeparateFile = JSON.parse(
fs.readFileSync(process.dirname + "/greenrhombus-scriptexts.json")
);
// eslint-disable-next-line
} catch (ex) {
}
const executeFastCGI = (
req,
res,
logFacilities,
defaultHeaders,
customEnv,
fastCGIConfig
) => {
const environment = Object.assign({}, process.env, customEnv);
let errorBuffer = "";
const options = {
host: fastCGIConfig.host,
port: fastCGIConfig.port,
path: fastCGIConfig.socketPath,
env: environment
};
const handler = new FastCGIHandler(options);
const handleData = createCGIParser(
req,
res,
handler,
defaultHeaders,
`GreenRhombus/${modInfo.version}`
);
handler.on("error", (error) => {
if (handler.headersParsed) return; // Don't call res.writeHead multiple times
const errorCode = ["ENOTFOUND", "EHOSTUNREACH", "ECONNREFUSED"].includes(
error.code
)
? 503
: error.code === "EMFILE"
? 429
: 500;
res.error(errorCode, `GreenRhombus/${modInfo.version}`, error);
});
res.prependListener("close", () => {
if (handler.stdout) handler.stdout.unpipe(res);
});
res.on("error", () => {}); // Suppress response stream errors
const handleConnection = () => {
handler.stdout.on("data", handleData);
handler.stderr.on("data", (data) => {
errorBuffer += data.toString();
});
handler.init();
req.pipe(handler.stdin);
handler.on("exit", (exitCode, signal) => {
if (!handler.headersParsed && (signal || exitCode !== 0)) {
const error = new Error(
`Process execution failed!${errorBuffer ? ` Reason: ${errorBuffer.trim()}` : ""}`
);
res.error(500, `GreenRhombus/${modInfo.version}`, error);
} else if (errorBuffer.trim()) {
logFacilities.errmessage("There were FastCGI application errors:");
logFacilities.errmessage(errorBuffer.trim());
}
handler.stdout.removeListener("data", handleData);
handler.stdout.unpipe(res);
if (!res.finished) res.end();
});
};
if (handler.socket.connecting === undefined || handler.socket.connecting) {
handler.on("connect", handleConnection);
} else if (!handler.socket.destroyed) {
handleConnection();
}
};
function executeFastCGIWithEnv(
scriptPath,
pathInfo,
req,
res,
logFacilities,
serverSoftware,
defaultHeaders,
fastCGIConfig,
wwwroot,
svrjsConfig
) {
// Function to set up environment variables and execute FastCGI scripts
const environmentVariables = {};
// Authentication details
if (req.authUser) {
if (req.headers.authorization) {
environmentVariables.AUTH_TYPE = req.headers.authorization.split(" ")[0];
}
environmentVariables.REMOTE_USER = req.authUser;
}
// Query string
const queryString = req.url.split("?")[1] || "";
environmentVariables.QUERY_STRING = queryString;
// Server details
environmentVariables.SERVER_SOFTWARE = serverSoftware;
environmentVariables.SERVER_PROTOCOL = `HTTP/${req.httpVersion}`;
if (req.socket.localAddress && req.socket.localPort) {
environmentVariables.SERVER_PORT = req.socket.localPort;
let localAddress = req.socket.localAddress.replace(/^::ffff:/i, "");
if (localAddress.includes(":")) {
localAddress = `[${localAddress}]`;
}
environmentVariables.SERVER_ADDR = localAddress;
}
if (
svrjsConfig.serverAdministratorEmail &&
svrjsConfig.serverAdministratorEmail !== "[no contact information]"
) {
environmentVariables.SERVER_ADMIN = svrjsConfig.serverAdministratorEmail;
}
environmentVariables.SERVER_NAME = req.headers.host;
environmentVariables.DOCUMENT_ROOT = wwwroot;
// Script details
const platform = os.platform();
const normalizePath = (path) =>
platform === "win32" ? path.replace(/\//g, "\\") : path;
environmentVariables.SCRIPT_NAME = scriptPath;
environmentVariables.SCRIPT_FILENAME = normalizePath(
`${wwwroot}${scriptPath}`
)
.replace(/\\+/g, "\\")
.replace(/\/\/+/, "/");
environmentVariables.PATH_INFO = decodeURIComponent(pathInfo);
environmentVariables.PATH_TRANSLATED = pathInfo
? normalizePath(`${wwwroot}${decodeURIComponent(pathInfo)}`)
: "";
// Request details
environmentVariables.REQUEST_METHOD = req.method;
environmentVariables.REQUEST_URI =
req.originalParsedURL &&
req.parsedURL &&
req.originalParsedURL.pathname === req.parsedURL.pathname
? req.url
: `${req.originalParsedURL.pathname}${req.parsedURL.search ? `?${req.parsedURL.search}` : ""}`;
const remoteAddress =
req.socket.realRemoteAddress ||
(svrjsConfig.enableIPSpoofing &&
req.headers["x-forwarded-for"] &&
req.headers["x-forwarded-for"].split(",")[0].trim()) ||
req.socket.remoteAddress;
environmentVariables.REMOTE_ADDR = remoteAddress.replace(/^::ffff:/i, "");
environmentVariables.REMOTE_PORT =
req.socket.realRemotePort || req.socket.remotePort;
if (req.socket.encrypted) {
environmentVariables.HTTPS = "ON";
}
// Content headers
if (req.headers["content-type"]) {
environmentVariables.CONTENT_TYPE = req.headers["content-type"];
}
environmentVariables.CONTENT_LENGTH = req.headers["content-length"] || "0";
// HTTP headers
const headersCopy = Object.assign({}, req.headers);
delete headersCopy["content-type"];
delete headersCopy["content-length"];
Object.keys(headersCopy).forEach((key) => {
const normalizedKey = key.replace(/[^0-9A-Za-z]+/g, "_").toUpperCase();
environmentVariables[`HTTP_${normalizedKey}`] = headersCopy[key];
});
executeFastCGI(
req,
res,
logFacilities,
defaultHeaders,
environmentVariables,
fastCGIConfig
);
}
const normalizedWebrootSupported =
process.versions.svrjs &&
process.versions.svrjs.match(
/^(?:Nightly-|(?:[5-9]|[1234][0-9])[0-9]*\.|4\.(?:(?:[1][0-9]|[2-9])+\.))/i
);
// Exported SVR.JS mod callback
module.exports = (req, res, logFacilities, config, next) => {
let scriptExts = config.fastCGIScriptExtensions
? config.fastCGIScriptExtensions
: scriptExtsFromSeparateFile;
let fastCGIConfig = config.fastCGIConfiguration
? config.fastCGIConfiguration
: fastcgiConfigFromSeparateFile;
const detectedWwwroot = normalizedWebrootSupported
? config.wwwroot
: process.cwd();
let ext = req.parsedURL.pathname.match(/[^/]\.([^.]+)$/);
if (!ext) ext = "";
else ext = ext[1].toLowerCase();
const isScriptExt = scriptExts.indexOf("." + ext) != -1;
let preparedFastCGIConfig = {};
if (fastCGIConfig.multiConfig) {
const hostnames = Object.keys(fastCGIConfig.multiConfig);
for (let i = 0; i < hostnames.length; i++) {
if (hostnames[i] == "*") {
preparedFastCGIConfig = Object.assign(
{},
fastCGIConfig.multiConfig["*"]
);
break;
} else if (
req.headers.host &&
hostnames[i].indexOf("*.") == 0 &&
hostnames[i] != "*."
) {
const hostnamesRoot = hostnames[i].substring(2);
if (
req.headers.host == hostnamesRoot ||
(req.headers.host.length > hostnamesRoot.length &&
req.headers.host.indexOf("." + hostnamesRoot) ==
req.headers.host.length - hostnamesRoot.length - 1)
) {
preparedFastCGIConfig = Object.assign(
{},
fastCGIConfig.multiConfig[hostnames[i]]
);
break;
}
} else if (req.headers.host && req.headers.host == hostnames[i]) {
preparedFastCGIConfig = Object.assign(
{},
fastCGIConfig.multiConfig[hostnames[i]]
);
break;
}
}
} else {
preparedFastCGIConfig = Object.assign({}, fastCGIConfig);
delete preparedFastCGIConfig.multiConfig;
}
if (preparedFastCGIConfig.path !== undefined)
preparedFastCGIConfig.path = preparedFastCGIConfig.path.replace(
/([^/])\/+$/,
"$1"
);
if (preparedFastCGIConfig.socketPath === undefined) {
if (preparedFastCGIConfig.host === undefined)
preparedFastCGIConfig.host = "localhost";
if (preparedFastCGIConfig.port === undefined)
preparedFastCGIConfig.port = 4000;
}
if (
preparedFastCGIConfig.path !== undefined &&
(req.parsedURL.pathname == preparedFastCGIConfig.path ||
req.parsedURL.pathname.indexOf(preparedFastCGIConfig.path + "/") == 0)
) {
try {
executeFastCGIWithEnv(
decodeURIComponent(req.parsedURL.pathname),
req.parsedURL.pathname.replace(preparedFastCGIConfig.path, ""),
req,
res,
logFacilities,
config.getCustomHeaders()["Server"] +
(config.exposeModsInErrorPages ||
config.exposeModsInErrorPages === undefined
? " GreenRhombus/" + modInfo.version
: ""),
config.getCustomHeaders(),
preparedFastCGIConfig,
detectedWwwroot,
config
);
} catch (ex) {
res.error(500, `GreenRhombus/${modInfo.version}`, ex);
}
} else if (
((req.parsedURL.pathname == "/greenrhombus-config.json" ||
(os.platform() == "win32" &&
req.parsedURL.pathname.toLowerCase() == "/greenrhombus-config.json")) &&
path.normalize(__dirname + "/../../..") == detectedWwwroot) ||
((req.parsedURL.pathname == "/greenrhombus-scriptexts.json" ||
(os.platform() == "win32" &&
req.parsedURL.pathname.toLowerCase() ==
"/greenrhombus-scriptexts.json")) &&
path.normalize(__dirname + "/../../..") == detectedWwwroot)
) {
res.error(403, `GreenRhombus/${modInfo.version}`);
} else {
fs.stat(
detectedWwwroot + decodeURIComponent(req.parsedURL.pathname),
(err, stats) => {
if (!err) {
if (!stats.isFile()) {
if (scriptExts.indexOf(".php") != -1) {
fs.stat(
detectedWwwroot +
decodeURIComponent(req.parsedURL.pathname) +
"/index.php",
(e2, s2) => {
if (!e2 && s2.isFile()) {
try {
executeFastCGIWithEnv(
(
decodeURIComponent(req.parsedURL.pathname) +
"/index.php"
).replace(/\/+/g, "/"),
"",
req,
res,
logFacilities,
config.getCustomHeaders()["Server"] +
(config.exposeModsInErrorPages ||
config.exposeModsInErrorPages === undefined
? " GreenRhombus/" + modInfo.version
: ""),
config.getCustomHeaders(),
preparedFastCGIConfig,
detectedWwwroot,
config
);
} catch (ex) {
res.error(500, `GreenRhombus/${modInfo.version}`, ex);
}
} else {
next();
}
}
);
} else if (scriptExts.indexOf(".cgi") != -1) {
fs.stat(
detectedWwwroot +
decodeURIComponent(req.parsedURL.pathname) +
"/index.cgi",
(e3, s3) => {
if (!e3 && s3.isFile()) {
try {
executeFastCGIWithEnv(
(
decodeURIComponent(req.parsedURL.pathname) +
"/index.cgi"
).replace(/\/+/g, "/"),
"",
req,
res,
logFacilities,
config.getCustomHeaders()["Server"] +
(config.exposeModsInErrorPages ||
config.exposeModsInErrorPages === undefined
? " GreenRhombus/" + modInfo.version
: ""),
config.getCustomHeaders(),
preparedFastCGIConfig,
detectedWwwroot,
config
);
} catch (ex) {
res.error(500, `GreenRhombus/${modInfo.version}`, ex);
}
} else {
next();
}
}
);
} else {
next();
}
} else {
if (isScriptExt) {
try {
executeFastCGIWithEnv(
decodeURIComponent(req.parsedURL.pathname),
"",
req,
res,
logFacilities,
config.getCustomHeaders()["Server"] +
(config.exposeModsInErrorPages ||
config.exposeModsInErrorPages === undefined
? " GreenRhombus/" + modInfo.version
: ""),
config.getCustomHeaders(),
preparedFastCGIConfig,
detectedWwwroot,
config
);
} catch (ex) {
res.error(500, `GreenRhombus/${modInfo.version}`, ex);
}
} else {
next();
}
}
} else if (err && err.code == "ENOTDIR") {
const checkPath = (pth, cb, a) => {
// Function to check the path of the file and connect to the FastCGI server
const cpth = pth.split("/");
if (cpth.length < 2) {
cb(false);
return;
}
let b = [];
if (a) b = a.split("/");
fs.stat(detectedWwwroot + "/" + pth, function (err, stats) {
if (!err && stats.isFile()) {
cb({
scriptPath: pth,
pathInfo: a !== undefined ? "/" + a : ""
});
} else {
b.unshift(cpth.pop());
return checkPath(cpth.join("/"), cb, b.join("/"));
}
});
};
checkPath(
"." + decodeURIComponent(req.parsedURL.pathname),
function (pathp) {
if (!pathp) {
next();
} else {
let newext = pathp.scriptPath.match(/[^/]\.([^.]+)$/);
if (!newext) newext = "";
else newext = newext[1].toLowerCase();
if (scriptExts.indexOf(newext) != -1) {
try {
executeFastCGIWithEnv(
pathp.scriptPath.substring(1),
pathp.pathInfo,
req,
res,
logFacilities,
config.getCustomHeaders()["Server"] +
(config.exposeModsInErrorPages ||
config.exposeModsInErrorPages === undefined
? " GreenRhombus/" + modInfo.version
: ""),
config.getCustomHeaders(),
preparedFastCGIConfig,
detectedWwwroot,
config
);
} catch (ex) {
res.error(500, `GreenRhombus/${modInfo.version}`, ex);
}
} else {
next();
}
}
}
);
} else {
next(); //Invoke default error handler
}
}
);
}
};
module.exports.configValidators = {
fastCGIConfiguration: (value) =>
typeof value === "object" &&
value !== null &&
(value.path === undefined || typeof value.path === "string") &&
(value.socketPath === undefined || typeof value.socketPath === "string") &&
(value.host === undefined || typeof value.host === "string") &&
(value.port === undefined ||
(typeof value.port === "number" &&
value.port >= 0 &&
value.port <= 65535)) &&
(value.multiConfig === undefined ||
(typeof value.multiConfig === "object" &&
value.multiConfig !== null &&
Object.keys(value.multiConfig).every((host) => {
const hostValue = value.multiConfig[host];
return (
(hostValue.path === undefined ||
typeof hostValue.path === "string") &&
(hostValue.socketPath === undefined ||
typeof hostValue.socketPath === "string") &&
(hostValue.host === undefined ||
typeof hostValue.host === "string") &&
(hostValue.port === undefined ||
(typeof hostValue.port === "number" &&
hostValue.port >= 0 &&
hostValue.port <= 65535))
);
}))),
fastCGIScriptExtensions: (value) =>
Array.isArray(value) && value.every((ext) => typeof ext === "string")
};
module.exports.modInfo = modInfo;

101
src/utils/cgiParser.js Normal file
View file

@ -0,0 +1,101 @@
function createCGIParser(req, res, handler, defaultHeaders, software) {
let responseBuffer = "";
let headerEndIndex = -1;
const dataHandler = (data) => {
if (!dataHandler.headersParsed) {
responseBuffer += data.toString("latin1");
const headerMatch = responseBuffer.match(
/(?:\r\n\r\n|\n\r\n\r|\n\n|\r\r)/
);
if (headerMatch) {
dataHandler.headersParsed = true;
const endOfHeaders = headerMatch[0];
headerEndIndex = headerMatch.index;
const rawHeaders = responseBuffer
.substring(0, headerEndIndex)
.split(/(?:\r\n|\n\r|\n|\r)/);
let parsedHeaders = defaultHeaders || {};
let statusCode = 200;
let statusMessage = "OK";
if (rawHeaders[0].match(/^HTTP\//)) {
// eslint-disable-next-line no-unused-vars
const [ignore, code, message] =
rawHeaders.shift().match(/^[^ ]+ *([0-9]{3}) *(.*)/) || [];
if (code) {
statusCode = parseInt(code);
statusMessage = message;
}
} else if (rawHeaders[0].indexOf(":") === -1) {
// eslint-disable-next-line no-unused-vars
const [ignore, code, message] =
rawHeaders.shift().match(/^([0-9]{3}) *(.*)/) || [];
if (code) {
statusCode = parseInt(code);
statusMessage = message;
}
}
let hasLocationHeader = false;
rawHeaders.forEach((headerLine) => {
// eslint-disable-next-line no-unused-vars
const [ignore, name, value] =
headerLine.match(/^([^:]+): *(.*)/) || [];
if (name) {
if (name.toLowerCase() === "status") {
// eslint-disable-next-line no-unused-vars
const [ignore, code, message] =
value.match(/^([0-9]{3}) *(.*)/) || [];
if (code) {
statusCode = parseInt(code);
statusMessage = message;
}
} else if (name.toLowerCase() === "set-cookie") {
parsedHeaders["Set-Cookie"] = parsedHeaders["Set-Cookie"] || [];
parsedHeaders["Set-Cookie"].push(value);
} else {
if (name.toLowerCase() === "location") hasLocationHeader = true;
parsedHeaders[name] = value;
}
}
});
if ((statusCode < 300 || statusCode > 399) && hasLocationHeader) {
statusCode = 302;
statusMessage = "Found";
}
try {
res.writeHead(statusCode, statusMessage, parsedHeaders);
res.write(
Buffer.from(
responseBuffer.substring(headerEndIndex + endOfHeaders.length),
"latin1"
)
);
} catch (error) {
handler.removeAllListeners("exit");
handler.stdout.removeAllListeners("data");
res.error(500, software, error);
return;
}
}
} else if (!res.finished) {
res.write(data);
handler.stdout.removeListener("data", dataHandler);
handler.stdout.pipe(res, { end: false });
}
};
dataHandler.headersParsed = false;
return dataHandler;
}
module.exports = createCGIParser;

370
src/utils/fastCgi.js Normal file
View file

@ -0,0 +1,370 @@
const EventEmitter = require("events");
const stream = require("stream");
const net = require("net");
const constants = {
BEGIN_REQUEST: 1,
ABORT_REQUEST: 2,
END_REQUEST: 3,
PARAMS: 4,
STDIN: 5,
STDOUT: 6,
STDERR: 7,
DATA: 8,
GET_VALUES: 9,
GET_VALUES_RESULT: 10,
UNKNOWN_TYPE: 11,
RESPONDER: 1,
REQUEST_COMPLETE: 0,
CANT_MPX_CONN: 1,
OVERLOADED: 2,
UNKNOWN_ROLE: 3
};
function buildFastCGIPacket(type, requestID, content) {
const packet = Buffer.alloc(content.length + (content.length % 8) + 8);
packet.writeUInt8(1, 0); // version
packet.writeUInt8(type, 1); // type
packet.writeUInt16BE(requestID, 2); // requestId
packet.writeUInt16BE(content.length, 4); // contentLength
packet.writeUInt8(content.length % 8, 6); // paddingLength
packet.writeUInt8(0, 7); // reserved
Buffer.from(content).copy(packet, 8); // content
return packet;
}
function buildNameValuePair(name, value) {
if (typeof name == "undefined" || typeof value == "undefined")
return Buffer.alloc(0);
name = String(name);
value = String(value);
const nameLength = name.length;
const valueLength = value.length;
const nameLengthBytes = nameLength > 127 ? 4 : 1;
const valueLengthBytes = valueLength > 127 ? 4 : 1;
const pair = Buffer.alloc(
nameLength + valueLength + nameLengthBytes + valueLengthBytes
);
// nameLength
if (nameLengthBytes == 4) {
pair.writeUInt32BE(Math.min(0xffffffff, 0x80000000 + nameLength), 0);
} else {
pair.writeUInt8(nameLength, 0);
}
// valueLength
if (valueLengthBytes == 4) {
pair.writeUInt32BE(
Math.min(0xffffffff, 0x80000000 + valueLength),
nameLengthBytes
);
} else {
pair.writeUInt8(valueLength, nameLengthBytes);
}
Buffer.from(name).copy(pair, nameLengthBytes + valueLengthBytes); // nameData
Buffer.from(value).copy(
pair,
nameLengthBytes + valueLengthBytes + nameLength
); // valueData
return pair;
}
function writeFastCGIPacket(socket, type, requestID, content) {
if (content.length != 0) {
const contentSlices = Math.ceil(content.length / 65535);
let chunk = Buffer.alloc(0);
for (let i = 0; i < contentSlices; i++) {
let chunkOffset = 65536 * i;
const chunkSize = Math.min(65535, content.length - chunkOffset);
chunk = Buffer.alloc(chunkSize);
content.copy(chunk, 0, chunkOffset, chunkOffset + chunkSize);
socket.write(buildFastCGIPacket(type, requestID, chunk));
}
} else {
socket.write(buildFastCGIPacket(type, requestID, Buffer.alloc(0)));
}
}
function parseFastCGIPacket(packet) {
return {
fcgiVersion: packet.readUInt8(0), // version
type: packet.readUInt8(1), // type
requestID: packet.readUInt16BE(2), // requestId
content: packet.subarray(8, 8 + packet.readUInt16BE(4)) // contentLength
};
}
function FastCGIHandler(options) {
let requestID = options.requestID;
if (!requestID) requestID = 1;
const fastCGISocketHandler = (chunk) => {
let chunkIndex = 0;
while (
chunkIndex < chunk.length ||
(headerIndex == 8 && bodyIndex == packetBody.length && paddingLength == 0)
) {
if (headerIndex < 8) {
chunk.copy(
packetHeader,
headerIndex,
chunkIndex,
Math.min(chunk.length, chunkIndex + 8 - headerIndex)
);
const ic = Math.min(chunk.length - chunkIndex, 8 - headerIndex);
headerIndex += ic;
chunkIndex += ic;
if (headerIndex == 8) {
packetBody = Buffer.alloc(packetHeader.readUInt16BE(4));
paddingLength = packetHeader.readUInt8(6);
}
} else if (headerIndex == 8 && bodyIndex < packetBody.length) {
chunk.copy(
packetBody,
bodyIndex,
chunkIndex,
Math.min(chunk.length, chunkIndex + packetBody.length - bodyIndex)
);
const ic = Math.min(
chunk.length - chunkIndex,
packetBody.length - bodyIndex
);
bodyIndex += ic;
chunkIndex += ic;
} else if (
headerIndex == 8 &&
bodyIndex == packetBody.length &&
paddingIndex <= paddingLength
) {
const ic = Math.min(
chunk.length - chunkIndex,
paddingLength - paddingIndex
);
paddingIndex += ic;
chunkIndex += ic;
if (paddingIndex == paddingLength) {
headerIndex = 0;
bodyIndex = 0;
paddingIndex = 0;
const packet = Buffer.alloc(8 + packetBody.length + paddingLength);
packetHeader.copy(packet);
packetBody.copy(packet, 8);
processFastCGIPacket(packet);
}
}
}
};
let processFastCGIPacket = (packet) => {
const processedPacket = parseFastCGIPacket(packet);
if (processedPacket.requestID != requestID) return; // Drop the packet
if (processedPacket.type == constants.STDOUT) {
try {
if (processedPacket.content.length > 0)
stdoutPush(processedPacket.content);
// eslint-disable-next-line no-unused-vars
} catch (err) {
// STDOUT will be lost instead of crashing the server
}
} else if (processedPacket.type == constants.STDERR) {
try {
if (processedPacket.content.length > 0)
emulatedStderr.push(processedPacket.content);
// eslint-disable-next-line no-unused-vars
} catch (err) {
// STDERR will be lost anyway...
}
} else if (
processedPacket.type == constants.END_REQUEST &&
processedPacket.content.length > 5
) {
if (typeof socket !== "undefined") {
socket.removeListener("data", fastCGISocketHandler);
socket.removeAllListeners("error");
socket.addListener("error", () => {});
processFastCGIPacket = function () {};
try {
socket.end(); // Fixes connection not closing properly in Bun
// eslint-disable-next-line no-unused-vars
} catch (err) {
// It is already closed
}
}
const appStatus = processedPacket.content.readUInt32BE(0);
const protocolStatus = processedPacket.content.readUInt8(4);
if (protocolStatus != constants.REQUEST_COMPLETE) {
let err = new Error("Unknown error");
if (protocolStatus == constants.OVERLOADED) {
err = new Error("FastCGI server overloaded");
err.code = "EMFILE";
err.errno = 24;
} else if (protocolStatus == constants.UNKNOWN_ROLE) {
err = new Error("Role not supported by the FastCGI application");
} else if (protocolStatus == constants.CANT_MPX_CONN) {
err = new Error(
"Multiplexed connections not supported by the FastCGI application"
);
}
stdoutPush(null);
if (
emulatedStdout._readableState &&
emulatedStdout._readableState.flowing !== null &&
!emulatedStdout.endEmitted
) {
emulatedStdout.on("end", function () {
emulatedStderr.push(null);
eventEmitter.emit("error", err);
});
} else {
emulatedStderr.push(null);
eventEmitter.emit("error", err);
}
} else {
stdoutPush(null);
if (
emulatedStdout._readableState &&
emulatedStdout._readableState.flowing !== null &&
!emulatedStdout.endEmitted
) {
emulatedStdout.on("end", function () {
emulatedStderr.push(null);
eventEmitter.emit("exit", appStatus, null);
});
} else {
emulatedStderr.push(null);
eventEmitter.emit("exit", appStatus, null);
}
}
}
};
const eventEmitter = new EventEmitter();
let packetHeader = Buffer.alloc(8);
let packetBody = Buffer.alloc(0);
let paddingLength = 0;
let headerIndex = 0;
let bodyIndex = 0;
let paddingIndex = 0;
const emulatedStdin = new stream.Writable({
write: function (chunk, encoding, callback) {
try {
if (chunk.length != 0) {
writeFastCGIPacket(socket, constants.STDIN, requestID, chunk);
}
callback(null);
} catch (err) {
callback(err);
}
},
final: function (callback) {
try {
writeFastCGIPacket(socket, constants.STDIN, requestID, Buffer.alloc(0));
// eslint-disable-next-line no-unused-vars
} catch (err) {
// writing failed
}
callback();
}
});
function stdoutPush(data) {
if (data === null) {
stdoutToEnd = true;
} else {
stdoutBuffer = Buffer.concat([stdoutBuffer, Buffer.from(data)]);
}
for (let i = 0; i < hp.length; i++) {
const func = hp.shift();
if (func) func();
}
emulatedStdout.resume();
}
let stdoutBuffer = Buffer.alloc(0);
let stdoutToEnd = false;
const hp = [];
const emulatedStdout = new stream.Readable({
read: (n) => {
const s = emulatedStdout;
const handler = () => {
if (stdoutBuffer.length == 0) {
if (!stdoutToEnd) {
hp.push(handler);
s.pause();
} else {
s.push(null);
}
} else {
const bytesToPush = Math.min(stdoutBuffer.length, n);
const bufferToPush = stdoutBuffer.subarray(0, bytesToPush);
stdoutBuffer = stdoutBuffer.subarray(bytesToPush);
s.push(bufferToPush);
}
};
if (n != 0) handler();
}
});
emulatedStdout.pause(); // Reduce backpressure
const emulatedStderr = new stream.Readable({
read: function () {}
});
const init = () => {
// Begin the request
const beginPacket = Buffer.alloc(8);
beginPacket.writeUInt16BE(constants.RESPONDER, 0); // FastCGI responder
beginPacket.writeUInt8(0, 2); // Don't keep alive
writeFastCGIPacket(socket, constants.BEGIN_REQUEST, requestID, beginPacket);
// Environment variables
let envPacket = Buffer.alloc(0);
Object.keys(options.env).forEach((key) => {
envPacket = Buffer.concat([
envPacket,
buildNameValuePair(key, options.env[key])
]);
});
if (envPacket.length > 0)
writeFastCGIPacket(socket, constants.PARAMS, requestID, envPacket);
writeFastCGIPacket(socket, constants.PARAMS, requestID, Buffer.alloc(0));
};
eventEmitter.stdin = emulatedStdin;
eventEmitter.stdout = emulatedStdout;
eventEmitter.stderr = emulatedStderr;
eventEmitter.init = init;
// Create socket
const socket = net
.createConnection(options, () => {
eventEmitter.emit("connect");
})
.on("error", (err) => {
stdoutPush(null);
emulatedStderr.push(null);
eventEmitter.removeAllListeners("exit");
eventEmitter.emit("error", err);
})
.on("data", fastCGISocketHandler);
eventEmitter.socket = socket;
eventEmitter.requestID = requestID;
return eventEmitter;
}
module.exports = {
FastCGIHandler: FastCGIHandler,
constants: constants,
buildFastCGIPacket: buildFastCGIPacket,
buildNameValuePair: buildNameValuePair,
writeFastCGIPacket: writeFastCGIPacket,
parseFastCGIPacket: parseFastCGIPacket
};

View file

@ -0,0 +1,130 @@
const createCGIParser = require("../../src/utils/cgiParser.js");
jest.mock("http"); // Mocking HTTP to simulate the response behavior
describe("createCGIParser", () => {
let req, res, handler, defaultHeaders, software, dataHandler;
beforeEach(() => {
req = {};
res = {
writeHead: jest.fn(),
write: jest.fn(),
finished: false,
error: jest.fn()
};
handler = {
stdout: {
removeAllListeners: jest.fn(),
pipe: jest.fn(),
on: jest.fn()
},
removeAllListeners: jest.fn()
};
defaultHeaders = { "X-Test-Header": "TestValue" };
software = "TestSoftware";
});
test("should parse headers and write response correctly", () => {
const inputHeaders =
"Status: 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 13\r\n\r\nHello, world!";
const buffer = Buffer.from(inputHeaders, "latin1");
dataHandler = createCGIParser(req, res, handler, defaultHeaders, software);
dataHandler(buffer);
expect(res.writeHead).toHaveBeenCalledWith(200, "OK", {
"Content-Type": "text/plain",
"Content-Length": "13",
"X-Test-Header": "TestValue"
});
expect(res.write).toHaveBeenCalledWith(
Buffer.from("Hello, world!", "latin1")
);
});
test("should handle status headers without protocol", () => {
const inputHeaders =
"Status: 200 OK\r\nContent-Type: text/html\r\n\r\n<body>Success</body>";
const buffer = Buffer.from(inputHeaders, "latin1");
dataHandler = createCGIParser(req, res, handler, defaultHeaders, software);
dataHandler(buffer);
expect(res.writeHead).toHaveBeenCalledWith(200, "OK", {
"Content-Type": "text/html",
"X-Test-Header": "TestValue"
});
expect(res.write).toHaveBeenCalledWith(
Buffer.from("<body>Success</body>", "latin1")
);
});
test("should handle malformed status line", () => {
const inputHeaders =
"Content-Type: text/plain\r\n\r\nMalformed status line.";
const buffer = Buffer.from(inputHeaders, "latin1");
dataHandler = createCGIParser(req, res, handler, defaultHeaders, software);
dataHandler(buffer);
expect(res.writeHead).toHaveBeenCalledWith(200, "OK", {
"Content-Type": "text/plain",
"X-Test-Header": "TestValue"
});
expect(res.write).toHaveBeenCalledWith(
Buffer.from("Malformed status line.", "latin1")
);
});
test("should handle redirection logic for Location header", () => {
const inputHeaders = "Status: 302 Found\r\nLocation: /new-location\r\n\r\n";
const buffer = Buffer.from(inputHeaders, "latin1");
dataHandler = createCGIParser(req, res, handler, defaultHeaders, software);
dataHandler(buffer);
expect(res.writeHead).toHaveBeenCalledWith(302, "Found", {
Location: "/new-location",
"X-Test-Header": "TestValue"
});
expect(res.write).toHaveBeenCalledWith(Buffer.from("", "latin1"));
});
test("should handle Set-Cookie headers correctly", () => {
const inputHeaders =
"Status: 200 OK\r\nSet-Cookie: sessionId=abc123; HttpOnly\r\nSet-Cookie: theme=dark; Secure\r\n\r\nBody content";
const buffer = Buffer.from(inputHeaders, "latin1");
dataHandler = createCGIParser(req, res, handler, defaultHeaders, software);
dataHandler(buffer);
expect(res.writeHead).toHaveBeenCalledWith(200, "OK", {
"Set-Cookie": ["sessionId=abc123; HttpOnly", "theme=dark; Secure"],
"X-Test-Header": "TestValue"
});
expect(res.write).toHaveBeenCalledWith(
Buffer.from("Body content", "latin1")
);
});
test("should call res.error on failure to writeHead or write", () => {
res.writeHead.mockImplementation(() => {
throw new Error("writeHead error");
});
const inputHeaders =
"Status: 500 Internal Server Error\r\n\r\nError occurred.";
const buffer = Buffer.from(inputHeaders, "latin1");
dataHandler = createCGIParser(req, res, handler, defaultHeaders, software);
dataHandler(buffer);
expect(res.error).toHaveBeenCalledWith(
500,
"TestSoftware",
expect.any(Error)
);
expect(handler.removeAllListeners).toHaveBeenCalled();
expect(handler.stdout.removeAllListeners).toHaveBeenCalled();
});
});

150
tests/utils/fastCgi.test.js Normal file
View file

@ -0,0 +1,150 @@
const {
FastCGIHandler,
constants,
buildFastCGIPacket,
buildNameValuePair,
writeFastCGIPacket
} = require("../../src/utils/fastCgi.js");
const net = require("net");
const EventEmitter = require("events");
jest.mock("net");
describe("FastCGI module", () => {
let mockSocket;
beforeEach(() => {
mockSocket = {
write: jest.fn(),
on: jest.fn().mockImplementation((event, handler) => {
if (event === "data") {
mockSocket.dataHandler = handler;
}
return mockSocket; // Enable method chaining
}),
removeListener: jest.fn().mockImplementation((event, handler) => {
if (event === "data" && mockSocket.dataHandler == handler) {
mockSocket.dataHandler = () => {};
}
return mockSocket; // Enable method chaining
}),
dataHandler: () => {}
};
net.createConnection.mockReturnValue(mockSocket);
});
afterEach(() => {
jest.clearAllMocks();
});
test("should create a socket and connect", () => {
const handler = new FastCGIHandler({ host: "localhost", port: 9000 });
expect(net.createConnection).toHaveBeenCalledWith(
{ host: "localhost", port: 9000 },
expect.any(Function)
);
expect(mockSocket.on).toHaveBeenCalledWith("error", expect.any(Function));
expect(mockSocket.on).toHaveBeenCalledWith("data", expect.any(Function));
expect(handler).toBeInstanceOf(EventEmitter);
});
test("should build a FastCGI packet", () => {
const content = Buffer.from("test");
const packet = buildFastCGIPacket(1, 1, content);
expect(packet.length).toBe(16); // Header + padded content
expect(packet.readUInt8(0)).toBe(1); // version
expect(packet.readUInt8(1)).toBe(1); // type
expect(packet.readUInt16BE(2)).toBe(1); // requestID
expect(packet.readUInt16BE(4)).toBe(4); // content length
expect(packet.readUInt8(6)).toBe(4); // padding length
});
test("should write FastCGI packets to the socket", () => {
const handler = new FastCGIHandler({});
const content = Buffer.from("test");
writeFastCGIPacket(handler.socket, 1, handler.requestID, content);
expect(mockSocket.write).toHaveBeenCalledTimes(1);
expect(mockSocket.write).toHaveBeenCalledWith(expect.any(Buffer));
});
// eslint-disable-next-line jest/no-done-callback
test("should handle socket data and emit events", (done) => {
const handler = new FastCGIHandler({});
handler.stdout.on("data", (chunk) => {
expect(chunk).toStrictEqual(Buffer.from("Hello World"));
done();
});
// Simulate a FastCGI STDOUT packet
const packet = buildFastCGIPacket(
constants.STDOUT,
handler.requestID,
Buffer.from("Hello World")
);
mockSocket.dataHandler(packet);
});
// eslint-disable-next-line jest/no-done-callback
test("should handle END_REQUEST and emit 'exit' event", (done) => {
const handler = new FastCGIHandler({});
handler.on("exit", (code, signal) => {
expect(code).toBe(0);
expect(signal).toBeNull();
done();
});
// Simulate an END_REQUEST packet
const content = Buffer.alloc(8);
content.writeUInt32BE(0, 0); // appStatus
content.writeUInt8(0, 4); // protocolStatus
const packet = buildFastCGIPacket(
constants.END_REQUEST,
handler.requestID,
content
);
mockSocket.dataHandler(packet);
});
test("should handle UNKNOWN_TYPE and discard it", () => {
const handler = new FastCGIHandler({});
const dataListener = jest.fn();
handler.stdout.on("data", dataListener);
// Simulate an UNKNOWN_TYPE packet
const packet = buildFastCGIPacket(
constants.UNKNOWN_TYPE,
handler.requestID,
Buffer.alloc(0)
);
mockSocket.dataHandler(packet);
expect(dataListener).not.toHaveBeenCalledWith();
});
test("should build name-value pairs correctly", () => {
const pair = buildNameValuePair("key", "value");
expect(pair.length).toBe(10); // 1 byte for key length, 1 for value length, and key-value content
expect(pair.toString("utf8", 2)).toBe("keyvalue");
});
test("should send environment variables on init", () => {
const env = { FOO: "BAR", BAZ: "QUX" };
const handler = new FastCGIHandler({ env });
handler.init();
expect(mockSocket.write).toHaveBeenCalled();
const calls = mockSocket.write.mock.calls;
const envPacket = calls.find(([arg]) => arg.includes(Buffer.from("FOO")));
expect(envPacket).toBeTruthy();
});
});