Skip to content

Commit 05d92e3

Browse files
authored
Merge branch 'master' into master
2 parents b3294d3 + cf063f0 commit 05d92e3

28 files changed

+1169
-383
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ npm-debug.log*
33
.nyc_*/
44
.dir-locals.el
55
.DS_Store
6+
.tap

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Copyright (c) 2011-2022 Charlie Robbins, Marak Squires, Jade Michael Thornton and the Contributors.
1+
Copyright (c) 2011-2025 Charlie Robbins, Marak Squires, Jade Michael Thornton and the Contributors.
22

33
Permission is hereby granted, free of charge, to any person obtaining
44
a copy of this software and associated documentation files (the

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,17 @@ with the provided Dockerfile.
6969
|`-b` or `--brotli`|When enabled it will serve `./public/some-file.js.br` in place of `./public/some-file.js` when a brotli compressed version of the file exists and the request accepts `br` encoding. If gzip is also enabled, it will try to serve brotli first. |`false`|
7070
|`-e` or `--ext` |Default file extension if none supplied |`html` |
7171
|`-s` or `--silent` |Suppress log messages from output | |
72-
|`--cors` |Enable CORS via the `Access-Control-Allow-Origin` header | |
72+
|`--cors` | Enable CORS via the `Access-Control-Allow-Origin: *` header. Optionally provide comma-separated values to add to `Access-Control-Allow-Headers` | |
7373
|`-H` or `--header` |Add an extra response header (can be used several times) | |
7474
|`-o [path]` |Open browser window after starting the server. Optionally provide a URL path to open. e.g.: -o /other/dir/ | |
7575
|`-c` |Set cache time (in seconds) for cache-control max-age header, e.g. `-c10` for 10 seconds. To disable caching, use `-c-1`.|`3600` |
7676
|`-U` or `--utc` |Use UTC time format in log messages.| |
7777
|`--log-ip` |Enable logging of the client's IP address |`false` |
7878
|`-P` or `--proxy` |Proxies all requests which can't be resolved locally to the given url. e.g.: -P http://someurl.com | |
7979
|`--proxy-options` |Pass proxy [options](https://github.com/http-party/node-http-proxy#options) using nested dotted objects. e.g.: --proxy-options.secure false | |
80-
|`--proxy-config` |Pass in `.json` configuration file. e.g.: `./path/to/config.json` | |
80+
|`--proxy-config` |Pass in `.json` configuration file or stringified JSON. e.g.: `./path/to/config.json` | |
81+
|`--proxy-all` |Forward every request to the proxy target instead of serving local files|`false`|
82+
|`--proxy-options` |Pass proxy [options](https://github.com/http-party/node-http-proxy#options) using nested dotted objects. e.g.: --proxy-options.secure false |
8183
|`--username` |Username for basic authentication | |
8284
|`--password` |Password for basic authentication | |
8385
|`-S`, `--tls` or `--ssl` |Enable secure request serving with TLS/SSL (HTTPS)|`false`|

bin/http-server

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ var argv = require('minimist')(process.argv.slice(2), {
1414
alias: {
1515
tls: 'ssl',
1616
header: 'H'
17-
}
17+
},
18+
boolean: ['proxy-all']
1819
});
20+
1921
var ifaces = os.networkInterfaces();
2022

2123
process.title = 'http-server';
@@ -33,10 +35,19 @@ if (argv.h || argv.help) {
3335
' -g --gzip Serve gzip files when possible [false]',
3436
' -b --brotli Serve brotli files when possible [false]',
3537
' If both brotli and gzip are enabled, brotli takes precedence',
38+
'',
39+
' --force-content-encoding',
40+
' When using --gzip or --brotli, includes the content encoding',
41+
' header even when the extension for the compressed file is',
42+
' specified in the URL. "test.png.br" will be served the same',
43+
' way as "test.png".',
44+
'',
3645
' -e --ext Default file extension if none supplied [none]',
3746
' -s --silent Suppress log messages from output',
47+
' --content-type Default content type for unknown file types [application/octet-stream]',
3848
' --cors[=headers] Enable CORS via the "Access-Control-Allow-Origin" header',
39-
' Optionally provide CORS headers list separated by commas',
49+
' When enabled, sets Access-Control-Allow-Origin to "*"',
50+
' Optional value adds to Access-Control-Allow-Headers',
4051
' -H',
4152
' --header',
4253
' Add an extra response header (can be used several times)',
@@ -50,8 +61,10 @@ if (argv.h || argv.help) {
5061
' --log-ip Enable logging of the client\'s IP address',
5162
'',
5263
' -P --proxy Fallback proxy if the request cannot be resolved. e.g.: http://someurl.com',
64+
' --proxy-all Send every request to the proxy target instead of serving local files',
5365
' --proxy-options Pass options to proxy using nested dotted objects. e.g.: --proxy-options.secure false',
5466
' --proxy-config Pass in .json configuration file. e.g.: ./path/to/config.json',
67+
' --websocket Enable websocket proxy',
5568
'',
5669
' --username Username for basic authentication [none]',
5770
' Can also be specified with the env variable NODE_HTTP_SERVER_USERNAME',
@@ -78,6 +91,8 @@ var port = argv.p || argv.port || parseInt(process.env.PORT, 10),
7891
proxy = argv.P || argv.proxy,
7992
proxyOptions = argv['proxy-options'],
8093
proxyConfig = argv['proxy-config'],
94+
websocket = argv.websocket,
95+
proxyAll = Boolean(argv['proxy-all']),
8196
utc = argv.U || argv.utc,
8297
version = argv.v || argv.version,
8398
baseDir = argv['base-dir'],
@@ -166,8 +181,10 @@ function listen(port) {
166181
proxy: proxy,
167182
proxyOptions: proxyOptions,
168183
proxyConfig: proxyConfig,
184+
proxyAll: proxyAll,
169185
showDotfiles: argv.dotfiles,
170186
mimetypes: argv.mimetypes,
187+
contentType: argv['content-type'],
171188
username: argv.username || process.env.NODE_HTTP_SERVER_USERNAME,
172189
password: argv.password || process.env.NODE_HTTP_SERVER_PASSWORD,
173190
headers: {}
@@ -182,12 +199,24 @@ function listen(port) {
182199
}
183200
}
184201

202+
if (websocket) {
203+
if (!proxy) {
204+
logger.warning(colors.yellow('WebSocket proxy will not be enabled because proxy is not enabled'));
205+
} else {
206+
options.websocket = true;
207+
}
208+
}
209+
185210
if (argv.cors) {
186211
options.cors = true;
187212
if (typeof argv.cors === 'string') {
188213
options.corsHeaders = argv.cors;
189214
}
190215
}
216+
217+
if ( argv['force-content-encoding'] ) {
218+
options.forceContentEncoding = true;
219+
}
191220

192221
if (argv.header) {
193222
if (Array.isArray(argv.header)) {
@@ -229,6 +258,12 @@ function listen(port) {
229258
proxyOptions = undefined;
230259
}
231260

261+
// TODO: handle this in respect of proxyConfig
262+
if (proxyAll && !proxy) {
263+
logger.info(chalk.red('Error: --proxy-all requires --proxy to be set'));
264+
process.exit(1);
265+
}
266+
232267
if (tls) {
233268
options.https = {
234269
cert: argv.C || argv.cert || 'cert.pem',
@@ -293,7 +328,7 @@ function listen(port) {
293328
} else {
294329
Object.keys(ifaces).forEach(function (dev) {
295330
ifaces[dev].forEach(function (details) {
296-
if (details.family === 'IPv4') {
331+
if (details.family === 'IPv4' || details.family === 4) {
297332
logger.info((' ' + protocol + details.address + ':' + chalk.green(port.toString()) + path));
298333
}
299334
if (details.family === 'IPv6' && !details.address.startsWith("fe80") ) { // Ignoring Ipv6-Link Local addresses

doc/http-server.1

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ Serve brotli files when possible.
4949
If both brotli and gzip are enabled, brotli takes precedence.
5050
Default is false.
5151

52+
.TP
53+
.BI \-\-force\-content\-encoding
54+
When using --gzip or --brotli, includes the content encoding
55+
header even when the extension for the compressed file is
56+
specified in the URL. "test.png.br" will be served the same
57+
way as "test.png".
58+
5259
.TP
5360
.BI \-e ", " \-\-ext " " \fIEXTENSION\fR
5461
Default file extension is none is provided.
@@ -59,8 +66,9 @@ Suppress log messages from output.
5966

6067
.TP
6168
.BI \-\-cors " " [\fIHEADERS\fR]
62-
Enable CORS via the "Access-Control-Allow-Origin" header.
63-
Optionally provide CORS headers list separated by commas.
69+
Enable CORS by setting "Access-Control-Allow-Origin" to "*".
70+
Optional comma-separated headers list adds to "Access-Control-Allow-Headers".
71+
Default Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Range.
6472

6573
.TP
6674
.BI \-H ", " \-\-header " " \fIHEADER\fR
@@ -89,6 +97,11 @@ Enable logging of the client IP address.
8997
.BI \-P ", " \-\-proxy
9098
Fallback proxy if the request cannot be resolved.
9199

100+
.TP
101+
.BI \-\-proxy\-all
102+
Forward every request to the proxy target and disable local file serving.
103+
Requires \-\-proxy.
104+
92105
.TP
93106
.BI \-\-proxy\-options
94107
Pass proxy options using nested dotted objects.

lib/core/defaults.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"cors": false,
1010
"gzip": true,
1111
"brotli": false,
12+
"forceContentEncoding": false,
1213
"defaultExt": ".html",
1314
"handleError": true,
1415
"contentType": "application/octet-stream",

lib/core/index.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,14 @@ module.exports = function createMiddleware(_dir, _options) {
200200
path.relative(path.join('/', baseDir), pathname)
201201
)
202202
);
203-
// determine compressed forms if they were to exist
203+
// determine compressed forms if they were to exist, make sure to handle pre-compressed files, i.e. files with .br/.gz extension. we will serve them "as-is"
204204
gzippedFile = `${file}.gz`;
205205
brotliFile = `${file}.br`;
206+
207+
if ( opts.forceContentEncoding ) {
208+
if ( file.endsWith('.gz') ) gzippedFile = file;
209+
if ( file.endsWith('.br') ) brotliFile = file;
210+
}
206211

207212
Object.keys(headers).forEach((key) => {
208213
res.setHeader(key, headers[key]);

lib/core/opts.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module.exports = (opts) => {
1515
let cache = defaults.cache;
1616
let gzip = defaults.gzip;
1717
let brotli = defaults.brotli;
18+
let forceContentEncoding = defaults.forceContentEncoding;
1819
let defaultExt = defaults.defaultExt;
1920
let handleError = defaults.handleError;
2021
const headers = {};
@@ -28,12 +29,26 @@ module.exports = (opts) => {
2829
return typeof opts[k] !== 'undefined' && opts[k] !== null;
2930
}
3031

32+
function validateNoCRLF(str) {
33+
if (typeof str === 'string' && (str.includes('\r') || str.includes('\n'))) {
34+
throw new Error('Header is not a string or contains CRLF');
35+
}
36+
}
37+
38+
function addHeader(key, value) {
39+
validateNoCRLF(key);
40+
validateNoCRLF(value);
41+
headers[key] = value;
42+
}
43+
3144
function setHeader(str) {
45+
validateNoCRLF(str);
46+
3247
const m = /^(.+?)\s*:\s*(.*)$/.exec(str);
3348
if (!m) {
34-
headers[str] = true;
49+
addHeader(str, true); // Use addHeader instead of direct assignment
3550
} else {
36-
headers[m[1]] = m[2];
51+
addHeader(m[1], m[2]); // Use addHeader instead of direct assignment
3752
}
3853
}
3954

@@ -108,6 +123,9 @@ module.exports = (opts) => {
108123
if (typeof opts.brotli !== 'undefined' && opts.brotli !== null) {
109124
brotli = opts.brotli;
110125
}
126+
if (typeof opts.forceContentEncoding !== 'undefined' && opts.forceContentEncoding !== null) {
127+
forceContentEncoding = opts.forceContentEncoding;
128+
}
111129

112130
aliases.handleError.some((k) => {
113131
if (isDeclared(k)) {
@@ -131,7 +149,7 @@ module.exports = (opts) => {
131149
opts[k].forEach(setHeader);
132150
} else if (opts[k] && typeof opts[k] === 'object') {
133151
Object.keys(opts[k]).forEach((key) => {
134-
headers[key] = opts[k][key];
152+
addHeader(key, opts[k][key]); // Uses same validation path
135153
});
136154
} else {
137155
setHeader(opts[k]);
@@ -192,6 +210,7 @@ module.exports = (opts) => {
192210
baseDir: (opts && opts.baseDir) || '/',
193211
gzip,
194212
brotli,
213+
forceContentEncoding,
195214
handleError,
196215
headers,
197216
contentType,

lib/core/show-dir/index.js

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,50 @@ module.exports = (opts) => {
7373
res.setHeader('etag', etag(stat, weakEtags));
7474
res.setHeader('last-modified', (new Date(stat.mtime)).toUTCString());
7575
res.setHeader('cache-control', cache);
76+
77+
// A step before render() is called to gives items additional
78+
// information so that render() can deliver the best user experience
79+
// possible.
80+
function process(dirs, renderFiles, lolwuts) {
81+
const filenamesThatExist = new Set();
82+
83+
// Putting filenames in a set first keeps us in O(n) time complexity
84+
for (let i=0; i < renderFiles.length; i++) {
85+
const [name, stat] = renderFiles[i];
86+
filenamesThatExist.add(name);
87+
const renderOptions = {};
88+
renderFiles[i] = [name, stat, renderOptions];
89+
}
90+
91+
// Set render options for compressed files
92+
for (const [name, _stat, renderOptions] of renderFiles) {
93+
if (
94+
opts.brotli &&
95+
! opts.forceContentEncoding &&
96+
name.endsWith('.br')
97+
) {
98+
const uncompressedName = name.slice(0, -'.br'.length);
99+
if (filenamesThatExist.has(uncompressedName)) {
100+
continue;
101+
}
102+
renderOptions.uncompressedName = uncompressedName;
103+
}
104+
}
105+
for (const [name, _stat, renderOptions] of renderFiles) {
106+
if (
107+
opts.gzip &&
108+
! opts.forceContentEncoding &&
109+
name.endsWith('.gz')
110+
) {
111+
const uncompressedName = name.slice(0, -'.gz'.length);
112+
if (filenamesThatExist.has(uncompressedName)) {
113+
continue;
114+
}
115+
renderOptions.uncompressedName = uncompressedName;
116+
}
117+
}
118+
render(dirs, renderFiles, lolwuts);
119+
}
76120

77121
function render(dirs, renderFiles, lolwuts) {
78122
// each entry in the array is a [name, stat] tuple
@@ -94,7 +138,7 @@ module.exports = (opts) => {
94138

95139
const failed = false;
96140
const writeRow = (file) => {
97-
// render a row given a [name, stat] tuple
141+
// render a row given a [name, stat, renderOptions] tuple
98142
const isDir = file[1].isDirectory && file[1].isDirectory();
99143
let href = `./${encodeURIComponent(file[0])}`;
100144

@@ -103,7 +147,24 @@ module.exports = (opts) => {
103147
href += `/${he.encode((parsed.search) ? parsed.search : '')}`;
104148
}
105149

106-
const displayName = he.encode(file[0]) + ((isDir) ? '/' : '');
150+
// Handle compressed files with uncompressed names
151+
let displayNameHTML;
152+
let fileSize = sizeToString(file[1], humanReadable, si);
153+
154+
if (file[2] && file[2].uncompressedName) {
155+
// This is a compressed file, show both names with separate links
156+
const uncompressedName = he.encode(file[2].uncompressedName);
157+
const compressedName = he.encode(file[0]);
158+
const uncompressedHref = `./${encodeURIComponent(file[2].uncompressedName)}`;
159+
const asterisk = `<span title="served from compressed file">*</span>`;
160+
displayNameHTML = `<a href="${uncompressedHref}">${uncompressedName}</a>` +
161+
`${asterisk} (<a href="${href}">${compressedName}</a>)`;
162+
fileSize += '*';
163+
} else {
164+
// Regular file or directory
165+
displayNameHTML = `<a href="${href}">${he.encode(file[0]) + ((isDir) ? '/' : '')}</a>`;
166+
}
167+
107168
const ext = file[0].split('.').pop();
108169
const classForNonDir = supportedIcons[ext] ? ext : '_page';
109170
const iconClass = `icon-${isDir ? '_blank' : classForNonDir}`;
@@ -116,8 +177,8 @@ module.exports = (opts) => {
116177
}
117178
html +=
118179
`<td class="last-modified">${lastModifiedToString(file[1])}</td>` +
119-
`<td class="file-size"><code>${sizeToString(file[1], humanReadable, si)}</code></td>` +
120-
`<td class="display-name"><a href="${href}">${displayName}</a></td>` +
180+
`<td class="file-size"><code>${fileSize}</code></td>` +
181+
`<td class="display-name">${displayNameHTML}</td>` +
121182
'</tr>\n';
122183
};
123184

@@ -161,10 +222,10 @@ module.exports = (opts) => {
161222
return;
162223
}
163224
dirs.unshift(['..', s]);
164-
render(dirs, sortedFiles, lolwuts);
225+
process(dirs, sortedFiles, lolwuts);
165226
});
166227
} else {
167-
render(dirs, sortedFiles, lolwuts);
228+
process(dirs, sortedFiles, lolwuts);
168229
}
169230
});
170231
});

lib/core/show-dir/styles.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ css += 'table tr { white-space: nowrap; }\n';
99
css += 'td.perms {}\n';
1010
css += 'td.file-size { text-align: right; padding-left: 1em; }\n';
1111
css += 'td.display-name { padding-left: 1em; }\n';
12+
css += `
13+
@media (prefers-color-scheme: dark) {
14+
body {
15+
background-color: #303030;
16+
color: #efefef;
17+
}
18+
a {
19+
color: #ffff11;
20+
}
21+
}
22+
`;
1223

1324
Object.keys(icons).forEach((key) => {
1425
css += `i.icon-${key} {\n`;

0 commit comments

Comments
 (0)