From 3c856dd34806cb58486b3581c3865f231d66bec9 Mon Sep 17 00:00:00 2001 From: German Montenegro Date: Sun, 7 Jun 2026 13:55:12 -0300 Subject: [PATCH] add --sort option --- README.md | 1 + bin/http-server | 3 ++ doc/http-server.1 | 7 +++ lib/core/aliases.json | 1 + lib/core/opts.js | 18 ++++++++ lib/core/show-dir/index.js | 39 ++++++++++++++-- lib/http-server.js | 1 + package-lock.json | 6 +-- test/showdir-with-spaces.test.js | 79 ++++++++++++++++++++++++++++++++ 9 files changed, 147 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ce6bcf360..32979fe71 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ with the provided Dockerfile. |`-d` |Show directory listings |`true` | |`-dir-overrides-404` | Whether `-d` should override magic `404.html` | `false` |`-i` | Display autoIndex | `true` | +|`--sort` |Sort directory listings by `name`, `modified`, or `created`. `mtime` and `birthtime` are also accepted. Date sorting shows newest entries first. |`name` | |`-g` or `--gzip` |When enabled it will serve `./public/some-file.js.gz` in place of `./public/some-file.js` when a gzipped version of the file exists and the request accepts gzip encoding. If brotli is also enabled, it will try to serve brotli first.|`false`| |`-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`| |`-e` or `--ext` |Default file extension if none supplied |`html` | diff --git a/bin/http-server b/bin/http-server index d9820a523..1a092f7a6 100755 --- a/bin/http-server +++ b/bin/http-server @@ -30,6 +30,7 @@ if (argv.h || argv.help) { ' --dir-overrides-404 Whether -d should override magic 404.html [false]', ' --base-dir Base directory to serve files from [/]', ' -i Display autoIndex [true]', + ' --sort Sort directory listings by name, modified, or created [name]', ' -g --gzip Serve gzip files when possible [false]', ' -b --brotli Serve brotli files when possible [false]', ' If both brotli and gzip are enabled, brotli takes precedence', @@ -203,6 +204,7 @@ function listen(port) { dirOverrides404: argv['dir-overrides-404'], baseDir: baseDir, autoIndex: argv.i, + sort: argv.sort, gzip: argv.g || argv.gzip, brotli: argv.b || argv.brotli, robots: argv.r || argv.robots, @@ -363,6 +365,7 @@ function listen(port) { chalk.cyan(Number(argv.t) + ' seconds') : chalk.cyan('120 seconds'))].join('')), ([chalk.yellow('Directory Listings: '), argv.d ? chalk.red('not visible') : chalk.cyan('visible')].join('')), ([chalk.yellow('AutoIndex: '), argv.i ? chalk.red('not visible') : chalk.cyan('visible')].join('')), + ([chalk.yellow('Directory Listing Sort: '), argv.sort ? chalk.cyan(argv.sort) : chalk.cyan('name')].join('')), ([chalk.yellow('Serve GZIP Files: '), argv.g || argv.gzip ? chalk.cyan('true') : chalk.red('false')].join('')), ([chalk.yellow('Serve Brotli Files: '), argv.b || argv.brotli ? chalk.cyan('true') : chalk.red('false')].join('')), ([chalk.yellow('Default File Extension: '), argv.e ? chalk.cyan(argv.e) : (argv.ext ? chalk.cyan(argv.ext) : chalk.red('none'))].join('')), diff --git a/doc/http-server.1 b/doc/http-server.1 index e1f3b04be..46df97b93 100644 --- a/doc/http-server.1 +++ b/doc/http-server.1 @@ -43,6 +43,13 @@ Default is false. Display autoIndex. Default is true. +.TP +.BI \-\-sort " " \fISORT\fR +Sort directory listings by name, modified time, or creation time. +Accepted values are name, modified, mtime, created, and birthtime. +Date sorting shows newest entries first. +Default is name. + .TP .BI \-g ", " \-\-gzip Serve gzip files when possible. diff --git a/lib/core/aliases.json b/lib/core/aliases.json index 58c77b746..a72c7d0d5 100644 --- a/lib/core/aliases.json +++ b/lib/core/aliases.json @@ -1,5 +1,6 @@ { "autoIndex": [ "autoIndex", "autoindex" ], + "sort": [ "sort" ], "showDir": [ "showDir", "showdir" ], "dirOverrides404": [ "dirOverrides404", diff --git a/lib/core/opts.js b/lib/core/opts.js index d042c4064..1ae66dd56 100644 --- a/lib/core/opts.js +++ b/lib/core/opts.js @@ -6,6 +6,7 @@ const aliases = require('./aliases.json'); /** * @typedef {Object} ParsedOptions * @property {boolean} autoIndex + * @property {string} sort * @property {boolean} showDir * @property {boolean} dirOverrides404 * @property {boolean} showDotfiles @@ -37,6 +38,7 @@ module.exports = (opts) => { /** @type {ParsedOptions} */ const options = { autoIndex: true, + sort: 'name', showDir: true, dirOverrides404: false, showDotfiles: true, @@ -98,6 +100,22 @@ module.exports = (opts) => { return false; }); + aliases.sort.some((k) => { + if (isDeclared(k)) { + const sort = String(opts[k]).toLowerCase(); + const sortAliases = { + name: 'name', + modified: 'modified', + mtime: 'modified', + created: 'created', + birthtime: 'created', + }; + options.sort = sortAliases[sort] || 'name'; + return true; + } + return false; + }); + aliases.showDir.some((k) => { if (isDeclared(k)) { options.showDir = opts[k]; diff --git a/lib/core/show-dir/index.js b/lib/core/show-dir/index.js index 001e41a2c..28f957755 100644 --- a/lib/core/show-dir/index.js +++ b/lib/core/show-dir/index.js @@ -25,8 +25,39 @@ module.exports = (opts) => { const handleError = opts.handleError; const showDotfiles = opts.showDotfiles; const si = opts.si; + const sort = opts.sort; const weakEtags = opts.weakEtags; + function compareByName(a, b) { + return a[0].toString().localeCompare(b[0].toString()); + } + + function compareByDate(statName) { + return (a, b) => { + if (a[0] === '..' && b[0] !== '..') { + return -1; + } + if (b[0] === '..' && a[0] !== '..') { + return 1; + } + + const aTime = a[1][statName] instanceof Date ? a[1][statName].getTime() : 0; + const bTime = b[1][statName] instanceof Date ? b[1][statName].getTime() : 0; + + return bTime - aTime || compareByName(a, b); + }; + } + + function getListingComparator() { + if (sort === 'modified') { + return compareByDate('mtime'); + } + if (sort === 'created') { + return compareByDate('birthtime'); + } + return compareByName; + } + return function middleware(req, res, next) { // Figure out the path for the file from the given url const parsed = url.parse(req.url); @@ -182,9 +213,11 @@ module.exports = (opts) => { '\n'; }; - dirs.sort((a, b) => a[0].toString().localeCompare(b[0].toString())).forEach(writeRow); - renderFiles.sort((a, b) => a.toString().localeCompare(b.toString())).forEach(writeRow); - errs.sort((a, b) => a[0].toString().localeCompare(b[0].toString())).forEach(writeRow); + const listingComparator = getListingComparator(); + + dirs.sort(listingComparator).forEach(writeRow); + renderFiles.sort(listingComparator).forEach(writeRow); + errs.sort(compareByName).forEach(writeRow); html += '\n'; html += `
Node.js ${ diff --git a/lib/http-server.js b/lib/http-server.js index 76a12f97d..cd1b97568 100644 --- a/lib/http-server.js +++ b/lib/http-server.js @@ -224,6 +224,7 @@ function HttpServer(options) { showDotfiles: this.showDotfiles, hidePermissions: this.hidePermissions, autoIndex: this.autoIndex, + sort: options.sort, defaultExt: this.ext, dirOverrides404: this.dirOverrides404, gzip: this.gzip, diff --git a/package-lock.json b/package-lock.json index 19e759c56..9e7ee8f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -259,7 +259,6 @@ "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "license": "MIT", - "dev": true, "engines": { "node": "20 || >=22" } @@ -269,7 +268,6 @@ "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "license": "MIT", - "dev": true, "dependencies": { "@isaacs/balanced-match": "^4.0.1" }, @@ -8599,14 +8597,12 @@ "@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==" }, "@isaacs/brace-expansion": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, "requires": { "@isaacs/balanced-match": "^4.0.1" } diff --git a/test/showdir-with-spaces.test.js b/test/showdir-with-spaces.test.js index 30a1779cd..ddfd3c6e7 100644 --- a/test/showdir-with-spaces.test.js +++ b/test/showdir-with-spaces.test.js @@ -5,9 +5,14 @@ const ecstatic = require('../lib/core'); const http = require('http'); const request = require('request'); const path = require('path'); +const fs = require('fs'); +const os = require('os'); const root = `${__dirname}/public`; const baseDir = 'base'; +function getDisplayNameIndex(body, filename) { + return body.indexOf('>' + filename + '<'); +} test('directory listing when directory name contains spaces', (t) => { require('portfinder').getPort((err, port) => { @@ -33,3 +38,77 @@ test('directory listing when directory name contains spaces', (t) => { }); }); }); + +test('directory listing can sort files by modified time', (t) => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'http-server-sort-mtime-')); + const older = path.join(tmpRoot, 'older.txt'); + const newer = path.join(tmpRoot, 'newer.txt'); + + fs.writeFileSync(older, 'older'); + fs.writeFileSync(newer, 'newer'); + fs.utimesSync(older, new Date('2024-01-01T00:00:00Z'), new Date('2024-01-01T00:00:00Z')); + fs.utimesSync(newer, new Date('2024-01-02T00:00:00Z'), new Date('2024-01-02T00:00:00Z')); + + const server = http.createServer( + ecstatic({ + root: tmpRoot, + showDir: true, + autoIndex: false, + sort: 'modified', + }) + ); + + server.listen(0, () => { + const port = server.address().port; + request.get({ + uri: `http://localhost:${port}/`, + }, (err, res, body) => { + t.error(err); + t.equal(res.statusCode, 200); + t.ok( + getDisplayNameIndex(body, 'newer.txt') < getDisplayNameIndex(body, 'older.txt'), + 'newer modified file is listed first' + ); + server.close(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + t.end(); + }); + }); +}); + +test('directory listing can sort files by creation time', (t) => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'http-server-sort-birthtime-')); + const older = path.join(tmpRoot, 'older.txt'); + const newer = path.join(tmpRoot, 'newer.txt'); + + fs.writeFileSync(older, 'older'); + setTimeout(() => { + fs.writeFileSync(newer, 'newer'); + + const server = http.createServer( + ecstatic({ + root: tmpRoot, + showDir: true, + autoIndex: false, + sort: 'created', + }) + ); + + server.listen(0, () => { + const port = server.address().port; + request.get({ + uri: `http://localhost:${port}/`, + }, (err, res, body) => { + t.error(err); + t.equal(res.statusCode, 200); + t.ok( + getDisplayNameIndex(body, 'newer.txt') < getDisplayNameIndex(body, 'older.txt'), + 'newer created file is listed first' + ); + server.close(); + fs.rmSync(tmpRoot, { recursive: true, force: true }); + t.end(); + }); + }); + }, 20); +});