cgi-core
Version:
Minimalistic, zero-dependency wrapper for hosting CGI scripts with HTTP/1.1 support
203 lines (174 loc) • 5.62 kB
JavaScript
;
// cgi-core
// Thin wrapper for CGI scripts
// Copyright (c) 2024-2025 lfortin
//
// 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.
Object.defineProperty(exports, "__esModule", { value: true });
const { resolve } = require("node:path");
const { access, constants } = require("node:fs/promises");
const { spawn } = require("node:child_process");
const { getUrlFilePath, getExecPath, createEnvObject } = require("./lib/util");
const {
errorHandler,
terminateRequest,
streamRequestPayload,
streamResponsePayload,
} = require("./lib/wrapper");
const defaultExtensions =
process.platform === "win32"
? {
perl: ["pl", "cgi"],
python: ["py"],
node: ["js", "node"],
}
: {
"/usr/bin/perl": ["pl", "cgi"],
"/usr/bin/python": ["py"],
"/usr/local/bin/node": ["js", "node"],
};
const defaultConfig = {
urlPath: "/cgi-bin",
filePath: process.cwd(),
extensions: defaultExtensions,
indexExtension: "js",
debugOutput: false,
logRequests: false,
maxBuffer: 2 * 1024 ** 2,
requestChunkSize: 32 * 1024,
responseChunkSize: 32 * 1024,
requestTimeout: 30000,
forceKillDelay: 1000,
statusPages: {},
env: {},
};
function createHandler(configOptions = {}) {
const config = { ...defaultConfig, ...configOptions };
// Coerce numeric config options
const numericKeys = [
"maxBuffer",
"requestChunkSize",
"responseChunkSize",
"requestTimeout",
"forceKillDelay",
];
for (const key of numericKeys) {
const value = configOptions[key];
if (value !== undefined) {
const coerced = Number(value);
if (Number.isNaN(coerced)) {
throw new Error(`Invalid number for config.${key}`);
}
config[key] = coerced;
}
}
if (config.requestChunkSize > config.maxBuffer) {
throw new Error(`requestChunkSize cannot be greater than maxBuffer (${config.maxBuffer})`);
}
if (config.responseChunkSize > config.maxBuffer) {
throw new Error(`responseChunkSize cannot be greater than maxBuffer (${config.maxBuffer})`);
}
return async function (req, res) {
const filePath = getUrlFilePath(req.url, config);
if (filePath === null) {
return false;
}
req.pause();
if (parseInt(req.headers["content-length"], 10) > config.maxBuffer) {
terminateRequest(req, res, 413, config);
return true;
}
const fullFilePath = resolve(config.filePath, filePath);
const execPath = getExecPath(filePath, config.extensions);
const fullExecPath = `${execPath ? execPath + " " : ""}${fullFilePath}`;
let env;
try {
if (!filePath) {
throw new Error("missing filePath");
}
// Check file permissions
await access(fullFilePath, constants.F_OK);
await access(fullFilePath, constants.X_OK);
// Create the environment object
env = createEnvObject(req, {
filePath,
fullFilePath,
env: config.env,
});
} catch (err) {
if (err.code === "ENOENT") {
terminateRequest(req, res, 404, config);
return true;
}
errorHandler.apply({ req, res, config }, [err.message]);
return true;
}
const { cgiProcess, ac, timeoutId } = spawnProcess({
fullExecPath,
env,
config,
req,
res,
});
await streamRequestPayload(cgiProcess, req, config);
streamResponsePayload(cgiProcess, req, res, config);
res.on("close", () => {
ac.abort();
req.destroy();
clearTimeout(timeoutId);
});
return true;
};
}
function spawnProcess(params) {
const { fullExecPath, env, config, req, res } = params;
const ac = new AbortController();
const cgiProcess = spawn(fullExecPath, {
env,
shell: true,
windowsHide: true,
maxBuffer: config.maxBuffer,
signal: ac.signal,
});
const timeoutId = setTimeout(() => {
ac.abort();
terminateRequest(req, res, 504, config);
const forceKillTimeoutId = setTimeout(() => {
cgiProcess.kill("SIGKILL");
}, config.forceKillDelay);
cgiProcess.on("exit", () => {
clearTimeout(forceKillTimeoutId);
});
}, config.requestTimeout);
cgiProcess.on("close", (code) => {
clearTimeout(timeoutId);
//console.log(`child process exited with code ${code}`);
});
cgiProcess.on("error", (error) => {
clearTimeout(timeoutId);
});
cgiProcess.stderr.on("data", errorHandler.bind({ req, res, config }));
return { cgiProcess, ac, timeoutId };
}
module.exports = {
defaultExtensions,
defaultConfig,
createHandler,
};