Last active
April 18, 2025 08:51
-
-
Save tzvetkoff/89684671dd9432f07254 to your computer and use it in GitHub Desktop.
Simple NodeJS HTTPd
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
const | |
fs = require('fs'), | |
http = require('http'), | |
https = require('https') | |
path = require('path'), | |
url = require('url'); | |
class WebServer { | |
constructor(options) { | |
this.options = options; | |
const | |
_this = this, | |
ssl = !!options.cert, | |
serverOptions = ssl ? { cert: fs.readFileSync(options.cert), key: options.key ? fs.readFileSync(options.key) : undefined } : {}; | |
this.server = (ssl ? https : http).createServer(serverOptions, (request, response) => { | |
try { | |
_this.serve(request, response); | |
} catch (error) { | |
_this.logRequest(500, request, undefined, error); | |
_this.serveText(500, 'text/plain', '500 Internal Server Error\n\n' + error + '\n', request, response); | |
} | |
}); | |
} | |
start() { | |
this.server.listen(this.options.port || 1337, this.options.host || '0.0.0.0'); | |
} | |
serve(request, response) { | |
const | |
_this = this, | |
requestPath = unescape(url.parse(request.url).pathname), | |
filePath = path.join(process.cwd(), requestPath); | |
fs.stat(filePath, (error, st) => { | |
if (error) { | |
_this.logRequest(404, request, requestPath); | |
_this.serveText(404, 'text/plain', '404 File Not Found\n', request, response); | |
return; | |
} | |
if (st.isDirectory()) { | |
_this.serveDirectory(filePath, requestPath, request, response); | |
} else { | |
_this.serveFile(filePath, requestPath, request, response); | |
} | |
}); | |
} | |
serveText(status, contentType, text, request, response) { | |
response.writeHead(status, { 'Content-Length': Buffer.byteLength(text, 'utf8'), 'Content-Type': contentType }); | |
response.write(text); | |
response.end(); | |
} | |
serveFile(filePath, requestPath, request, response) { | |
const _this = this; | |
fs.stat(filePath, (error, stat) => { | |
if (error) { | |
_this.logRequest(404, request, requestPath); | |
_this.serveText(404, 'text/plain', '404 File Not Found\n', request, response); | |
return; | |
} | |
_this.logRequest(200, request, requestPath); | |
response.writeHead(200, { 'Content-Length': stat.size, 'Content-Type': _this.inferMimeType(filePath) }); | |
if (request.method === 'HEAD') { | |
response.end(); | |
} else { | |
fs.createReadStream(filePath).pipe(response, { end: true }); | |
} | |
}); | |
} | |
serveDirectory(dirPath, requestPath, request, response) { | |
const _this = this; | |
fs.readdir(dirPath, (error, items) => { | |
if (error) { | |
_this.logRequest(500, request, requestPath, error); | |
_this.serveText(500, 'text/plain', '500 Internal Server Error\n\n' + error + '\n', request, response); | |
return; | |
} | |
if (items.indexOf('index.html') !== -1) { | |
_this.serveFile(dirPath + '/index.html', requestPath, request, response); | |
return; | |
} | |
let html = '<!DOCTYPE html>'; | |
html += '<html>'; | |
html += '<head>'; | |
html += '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />'; | |
html += '<title>Index of ' + requestPath + '</title>'; | |
html += '<style>'; | |
html += '* { margin: 0; padding: 0; }'; | |
html += 'body { font: normal 12px/15px "Monospaced", monospace; color: #333333; background: #dcdcdc; }'; | |
html += 'header, footer, section { margin: 8px; padding: 8px; border: 1px solid #000000; background: #ffffff; }'; | |
html += 'footer { text-align: right; font-weight: bold; }'; | |
html += 'h1 { margin: 0; padding: 0; font: normal 12px/15px "Monospaced", monospace; }'; | |
html += 'table { width: 100%; border-collapse: collapse; }'; | |
html += 'tbody tr:nth-of-type(odd) { background: #f0f0f0; }'; | |
html += 'tbody tr:hover { background: #d8d8d8; }'; | |
html += 'th, td { padding: 1px 2px; }'; | |
html += 'th { border: 0 none; }'; | |
html += 'td { border: 1px solid #cccccc; white-space: nowrap; }'; | |
html += 'a, a:visited { text-decoration: none; color: #0000ff; }'; | |
html += 'a:hover { text-decoration: underline; color: #ff0000; }'; | |
html += '</style>'; | |
html += '</head>'; | |
html += '<body>'; | |
html += '<header><h1>Index of <strong>' + requestPath + '</strong></h1></header>'; | |
html += '<section>'; | |
html += '<table cellpadding="0" cellspasing="0">'; | |
html += '<colgroup>'; | |
html += '<col />'; | |
html += '<col width="1" />'; | |
html += '<col width="1" />'; | |
html += '</colgroup>'; | |
html += '<thead>'; | |
html += '<tr>'; | |
html += '<th align="left">Name</th>'; | |
html += '<th align="left">LastMod</th>'; | |
html += '<th align="right">Size</th>'; | |
html += '</tr>'; | |
html += '</thead>'; | |
html += '<tbody>'; | |
if (requestPath !== '/') { | |
html += '<tr>'; | |
html += '<td><a href="..">..</a>/</td>'; | |
html += '<td></td>'; | |
html += '<td></td>'; | |
html += '</tr>'; | |
} | |
const | |
pathPrefix = requestPath | |
.replace(/\/$/, '') | |
.split('/') | |
.map((item) => encodeURIComponent(item)) | |
.join('/'), | |
dirs = [], | |
files = [], | |
sortFunction = (lft, rgt) => lft[0].toLowerCase().localeCompare(rgt[0].toLowerCase()), | |
formatFileSizeFunction = (size) => { | |
const log = (Math.log(size) / Math.log(1024)) | 0; | |
return (size / Math.pow(1024, log)).toFixed(2) + (log ? 'kmgtpezy'[log - 1] + 'b' : 'b'); | |
}; | |
for (let i = 0, length = items.length; i < length; ++i) { | |
const | |
item = items[i], | |
path = dirPath + '/' + item, | |
stat = fs.statSync(path); | |
if (stat.isDirectory()) { | |
dirs.push([item, pathPrefix + '/' + encodeURIComponent(item) + '/', 'DIR', stat.mtime]); | |
} else { | |
files.push([item, pathPrefix + '/' + encodeURIComponent(item), formatFileSizeFunction(stat.size), stat.mtime]); | |
} | |
} | |
dirs.sort(sortFunction); | |
files.sort(sortFunction); | |
const combined = dirs.concat(files); | |
for (let i = 0, length = combined.length; i < length; ++i) { | |
const item = combined[i]; | |
html += '<tr>'; | |
html += '<td><a href="' + item[1] + '">' + item[0] + '</a>' + (item[2] === 'DIR' ? '/' : '') + '</td>'; | |
html += '<td>' + item[3].toISOString() + '</td>'; | |
html += '<td align="right">' + item[2] + '</td>'; | |
html += '</tr>'; | |
} | |
html += '</tbody>'; | |
html += '</table>'; | |
html += '</section>'; | |
html += '<footer>'; | |
html += '<em>© ' + new Date().getFullYear() + ' Latchezar Tzvetkoff</em>'; | |
html += '</footer>'; | |
html += '</body>'; | |
html += '</html>'; | |
_this.logRequest(200, request, requestPath); | |
_this.serveText(200, 'text/html', html, request, response); | |
}); | |
} | |
inferMimeType(filePath) { | |
const | |
extension = path.extname(filePath), | |
knownMimeTypes = [ | |
[/\.(html|css)$/, 'text/$1'], | |
[/\.js$/, 'text/javascript'], | |
[/\.(png|gif)$/, 'image/$1'], | |
[/\.jpe?g$/, 'image/jpeg'], | |
[/\.svg$/, 'image/svg+xml'], | |
[/\.(txt|c|cpp|h|hpp|cxx|hxx|php|rb|ac|am|in|pl|pm|sql|md)$/, 'text/plain'] | |
]; | |
for (let i = 0, length = knownMimeTypes.length; i < length; ++i) { | |
const | |
pair = knownMimeTypes[i], | |
regex = pair[0], | |
type = pair[1]; | |
if (regex.test(extension)) { | |
return extension.replace(regex, type); | |
} | |
} | |
return 'application/octet-stream'; | |
} | |
logRequest(status, request, requestPath, error) { | |
let message = request.socket.remoteAddress; | |
message += ' '.slice(message.length); | |
message += ' '; | |
message += status; | |
message += ' '; | |
message += request.method; | |
message += ' '; | |
message += requestPath; | |
if (error) { | |
message += ' [ '; | |
message += error; | |
message += ' ]'; | |
} | |
console.log(message); | |
} | |
} | |
var options = { | |
host: process.env.BIND_HOST || '0.0.0.0', | |
port: parseInt(process.env.BIND_PORT, 10) || 1337, | |
cert: process.env.TLS_CERT, | |
key: process.env.TLS_PRIVKEY, | |
}; | |
for (var i = 2, arg = process.argv[i]; i < process.argv.length; arg = process.argv[++i]) { | |
switch (arg) { | |
case '-h': | |
case '--help': | |
console.log('Usage:'); | |
console.log(' ' + process.argv[0] + ' ' + process.argv[1] + ' [options]'); | |
console.log(); | |
console.log('Options:'); | |
console.log(' -h, --help Print this message and exit'); | |
console.log(' -H H, --host=H Set bind host (env: BIND_HOST, default: 0.0.0.0)'); | |
console.log(' -P P, --port=P Set bind port (env: BIND_PORT, default: 1337)'); | |
console.log(' -C C, --tls-cert=C Set TLS certificate (env: TLS_CERT, default: undefined)'); | |
console.log(' -K K, --tls-privkey=K Set TLS private key (env: TLS_PRIVKEY, default: undefined)'); | |
process.exit(0); | |
case '-H': | |
case '--host': | |
options.host = process.argv[++i]; | |
break; | |
case '-P': | |
case '--port': | |
options.port = parseInt(process.argv[++i], 10); | |
break; | |
case '-C': | |
case '--tls-cert': | |
options.cert = process.argv[++i]; | |
break; | |
case '-K': | |
case '--tls-privkey': | |
options.key = process.argv[++i]; | |
break; | |
default: | |
if (arg.indexOf('-H') == 0) { | |
options.host = arg.slice(2); | |
} else if (arg.indexOf('--host=') == 0) { | |
options.host = arg.slice(7); | |
} else if (arg.indexOf('-P') == 0) { | |
options.port = parseInt(arg.slice(2), 10); | |
} else if (arg.indexOf('--port=') == 0) { | |
options.port = parseInt(arg.slice(7)); | |
} else if (arg.indexOf('-C') == 0) { | |
options.cert = arg.slice(2); | |
} else if (arg.indexOf('--tls-cert=') == 0) { | |
options.cert = arg.slice(11); | |
} else if (arg.indexOf('-K') == 0) { | |
options.key = arg.slice(2); | |
} else if (arg.indexOf('--tls-privkey=') == 0) { | |
options.key = arg.slice(14); | |
} else { | |
console.error('unknown option/argument: %s', arg); | |
process.exit(1); | |
} | |
} | |
} | |
if ((options.cert && !options.key) || (options.key && !options.key)) { | |
console.error('options --cert and --key are required together'); | |
process.exit(1); | |
} | |
new WebServer(options).start(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment