koa-classic-server
v3.0.1
Published
High-performance Koa middleware for serving static files with Apache-like directory listing, HTTP caching, template engine support, and comprehensive security fixes
Maintainers
Readme
koa-classic-server
🚀 Production-ready Koa middleware for serving static files with Apache2-like directory listing, sortable columns, pagination, hash-based CSP, template-engine timeouts, injectable logging, and enterprise-grade security.
🎉 Version 3.0 — File Server First, Observable, Bounded
The 3.0 series builds on 2.x with new observability hooks, bounded resource usage on accidentally-large directories, and a more focused design philosophy: koa-classic-server is an HTTP file server first — defaults serve files without applying surprise restrictions, and hardening is opt-in via explicit configuration plus a documented Security Checklist.
Key Features in Version 3.x
✅ Design philosophy made explicit — "if a file is in rootDir, GET returns it" — codified in CLAUDE.md, with a Security Checklist + Suggested Production Security Configuration in this README and docs/DOCUMENTATION.md
✅ dirListing namespace — listing options grouped under one structured object (enabled, maxEntries, entriesPerPage); the v2 showDirContents flag is kept as a deprecated alias with a one-time warning
✅ Soft cap on listing rendering — dirListing.maxEntries defaults to 100000 as a safety net against accidentally-huge directories (broken log rotation, mistakenly mounted FS), NOT as a policy restriction; banner + X-Dir-Truncated header on the rare hit. Opt-in RAM-bounded streaming reads planned for v3.1.
✅ Paginated listings — dirListing.entriesPerPage adds 0-based ?page=N navigation with First/Prev/Next/Last + X-Dir-Pagination header
✅ Template render timeout + AbortSignal — template.renderTimeout (default 30s) + a per-request template.signal so slow renders never wedge the server
✅ Injectable logger — pass any { error, warn, info, debug }-shaped logger (Pino, Bunyan, Winston, console) for full observability
✅ Hash-based CSP on listing page — automatic SHA-256 of inline CSS, recomputed at module load
✅ Security headers on generated pages — CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy on listing + error pages
✅ Sortable Directory Columns — Click Name/Type/Size to sort (Apache2-like) with sort/order preserved across paginator links
✅ HTTP Caching — ETag, Last-Modified, conditional 304 responses (opt-in via browserCacheEnabled)
✅ Template Engine Support — EJS, Pug, Handlebars, Nunjucks, and more — with full async/await, AbortSignal forwarding, and timeout enforcement
✅ Clean URLs — Hide file extensions via hideExtension (mod_rewrite-like)
✅ Symlink Support — Transparent resolution + clear indicators in the listing
✅ 532 tests passing — comprehensive coverage including security, listing pagination, logger injection, template timeouts, and edge cases
Features
koa-classic-server is a high-performance middleware for serving static files with Apache2-like behavior, making file browsing intuitive, observable, and safe.
Core Features
- 🗂️ Apache2-like Directory Listing — Sortable columns (Name, Type, Size)
- 📄 Static File Serving — Automatic MIME type detection with streaming
- 📊 Sortable Columns — Click headers to sort ascending/descending
- 📏 File Sizes — Human-readable display (B, KB, MB, GB, TB)
- 📃 Bounded + Paginated Listings —
dirListing.maxEntriescap +dirListing.entriesPerPagenavigation - ⏱️ Template Render Timeout — Configurable timeout with AbortSignal propagation
- 📝 Injectable Logger — Plug Pino/Bunyan/Winston/console at construction time
- ⚡ HTTP Caching — ETag, Last-Modified, 304 responses (opt-in)
- 🎨 Template Engine Support — EJS, Pug, Handlebars, Nunjucks, etc.
- 🔒 Enterprise Security — Path traversal, XSS, race condition protection, CSP, dot-file hiding
- ⚙️ Highly Configurable — URL prefixes, reserved paths, index files, hidden patterns
- 🚀 High Performance — Async/await, non-blocking I/O, single-syscall directory reads
- 🔗 Symlink Support — Transparent resolution with directory listing indicators
- 🌐 Clean URLs — Hide file extensions for SEO-friendly URLs via
hideExtension - 🧪 Well-Tested — 532 passing tests with comprehensive coverage
- 📦 Dual Module Support — CommonJS and ES Modules
Installation
npm install koa-classic-serverRequirements:
- Node.js >= 18.0.0
- Koa >= 2.0.0 (Koa 3 requires >= 3.1.2)
Quick Start
Basic Usage
const Koa = require('koa');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
// Serve files from "public" directory
app.use(koaClassicServer(__dirname + '/public'));
app.listen(3000);
console.log('Server running on http://localhost:3000');With Options
const Koa = require('koa');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
app.use(koaClassicServer(__dirname + '/public', {
index: ['index.html', 'index.htm'],
urlPrefix: '/static',
dirListing: {
enabled: true,
maxEntries: 5000, // cap huge directories
entriesPerPage: 50, // 50 entries per listing page
},
browserCacheEnabled: true,
browserCacheMaxAge: 3600,
}));
app.listen(3000);Complete Usage Guide
1. Import
// CommonJS
const koaClassicServer = require('koa-classic-server');
// ES Modules
import koaClassicServer from 'koa-classic-server';2. Basic File Server
const Koa = require('koa');
const path = require('path');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
app.use(koaClassicServer(path.join(__dirname, 'public'), {
dirListing: { enabled: true },
index: ['index.html'],
}));
app.listen(3000);3. With URL Prefix
app.use(koaClassicServer(__dirname + '/public', {
urlPrefix: '/static',
}));
// http://localhost:3000/static/image.png → public/image.png4. With Reserved Paths
app.use(koaClassicServer(__dirname, {
urlsReserved: ['/api', '/admin', '/.git', '/node_modules'],
}));
// /api/* is passed through to the next middleware untouched.5. Bounded + Paginated Directory Listings (V3)
For directories that may grow without bound (uploads, archives, logs), cap the maximum number of entries the middleware will enumerate and paginate what's visible:
app.use(koaClassicServer(__dirname + '/uploads', {
dirListing: {
enabled: true,
maxEntries: 10000, // cap visible / sorted / stat'd entries (default; 0 = disabled)
entriesPerPage: 100, // entries per page in the listing UI (default; 0 = disabled)
},
}));What happens on a directory with 1,000,000 files
- The middleware calls
fs.promises.readdir()once and slices the result todirListing.maxEntries— sorting, stat'ing, and rendering are CPU-bounded bydirListing.maxEntries. The initialreaddir()itself is not bounded (see v3.1 roadmap for an opt-in streaming mode targeting adversarial-directory workloads). - A yellow banner appears at the top of the listing: "Showing first 10000 entries (cap reached)…"
- The response carries
X-Dir-Truncated: 10000so monitoring can flag capped pages. - Pagination is rendered below the table with
« First · ‹ Prev · 0 1 … N · Next › · Last », and anX-Dir-Pagination: <current>/<last>response header is set. - Navigate via
?page=N(0-based). Out-of-range values clamp silently to the nearest valid page. Activesort/orderquery params are preserved across paginator links.
6. Template Engine with Timeout + AbortSignal (V3)
V3 hardens template rendering against runaway or hung renders: the middleware enforces a configurable timeout and forwards a template.signal (AbortSignal) you can use inside your renderer to abort I/O and long-running work.
const ejs = require('ejs');
const koaClassicServer = require('koa-classic-server');
app.use(koaClassicServer(__dirname + '/views', {
template: {
ext: ['ejs'],
renderTimeout: 5000, // 5s hard cap (default 30000ms; 0 disables the cap)
render: async (ctx, next, filePath, { signal }) => {
// Forward the signal to your I/O — fetch, DB queries, async work.
const data = await fetchData({ signal });
if (signal.aborted) return;
ctx.body = await ejs.renderFile(filePath, data);
ctx.type = 'text/html';
},
},
}));If the renderer exceeds renderTimeout, the request fails closed with a 500 and a single warning is emitted via the configured logger — the response stream is never left half-written.
7. Injectable Logger (V3)
By default the middleware logs to console. Pass any object exposing error, warn, info, debug to integrate with your production logging stack:
const pino = require('pino')();
app.use(koaClassicServer(__dirname + '/public', {
logger: pino, // any { error, warn, info, debug }-shaped object works
}));- Backward compatible: when
loggeris omitted, behavior is unchanged (usesconsole). - All internal warnings and errors flow through the same logger — useful for routing them to Sentry, Datadog, or stdout JSON.
8. Hidden Files & Dot-File Protection (V3 default: hidden)
Dot-files and dot-directories are visible by default in v3 — aligned with the "file server first" philosophy (see CLAUDE.md). For production deployments where .env, .git/config, etc. could be served accidentally, opt into hardening explicitly via hidden.dotFiles.default: 'hidden'. This is the first item on the Security Checklist.
app.use(koaClassicServer(__dirname + '/www', {
hidden: {
dotFiles: {
default: 'hidden', // 'hidden' | 'visible'
whitelist: ['.well-known', '.htaccess'],// exact name, glob, or RegExp
blacklist: [], // overrides whitelist
},
dotDirs: {
default: 'visible',
whitelist: [],
blacklist: ['.git'],
},
alwaysHide: ['*.key', /secret/i, '/private/**'], // path-aware patterns
},
}));9. Clean URLs with hideExtension
Serve .ejs (or any extension) as extensionless URLs and 301-redirect the canonical form:
app.use(koaClassicServer(__dirname + '/views', {
hideExtension: {
ext: '.ejs', // required, must start with '.'
redirect: 301, // optional, 301 (default) or 302
},
template: {
ext: ['ejs'],
render: async (ctx, next, filePath) => {
ctx.body = await ejs.renderFile(filePath);
ctx.type = 'text/html';
},
},
}));
// GET /about → serves views/about.ejs
// GET /about.ejs → 301 redirect to /about10. URL Rewriting Support (useOriginalUrl)
Set useOriginalUrl: false when running behind i18n routers or path-rewriters that mutate ctx.url:
app.use(async (ctx, next) => {
if (ctx.path.startsWith('/it/')) {
ctx.url = ctx.path.replace(/^\/it/, ''); // /it/page.html → /page.html
}
await next();
});
app.use(koaClassicServer(__dirname + '/www', {
useOriginalUrl: false, // use ctx.url (rewritten) instead of ctx.originalUrl
}));11. HTTP Caching (opt-in)
app.use(koaClassicServer(__dirname + '/public', {
browserCacheEnabled: true, // emit ETag + Last-Modified, honor If-None-Match / If-Modified-Since
browserCacheMaxAge: 86400, // Cache-Control: max-age=86400 (24h)
}));Defaults: browserCacheEnabled: false (development-friendly). Enable in production for an 80–95% bandwidth reduction on cache hits.
12. Complete Production Example
const Koa = require('koa');
const path = require('path');
const pino = require('pino')({ level: 'info' });
const ejs = require('ejs');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
// Allowlist Host headers to mitigate DNS rebinding (see docs/DOCUMENTATION.md → Sicurezza).
const ALLOWED_HOSTS = new Set(['app.example.com', 'localhost:3000']);
app.use(async (ctx, next) => {
if (!ALLOWED_HOSTS.has(ctx.host)) { ctx.status = 421; ctx.body = 'Host not allowed'; return; }
await next();
});
// Static-file security headers (see docs/DOCUMENTATION.md → Limiti dei Security Headers).
app.use(async (ctx, next) => {
ctx.set('X-Content-Type-Options', 'nosniff');
ctx.set('Referrer-Policy', 'strict-origin-when-cross-origin');
ctx.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
await next();
});
app.use(koaClassicServer(path.join(__dirname, 'public'), {
index: ['index.html'],
dirListing: {
enabled: process.env.NODE_ENV !== 'production',
maxEntries: 10000,
entriesPerPage: 100,
},
browserCacheEnabled: true,
browserCacheMaxAge: 86400,
logger: pino,
hidden: {
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
alwaysHide: ['*.key', /^backup-/],
},
template: {
ext: ['ejs'],
renderTimeout: 5000,
render: async (ctx, next, filePath, { signal }) => {
ctx.body = await ejs.renderFile(filePath, { user: ctx.state.user }, { signal });
ctx.type = 'text/html';
},
},
}));
app.listen(3000);API Reference
koaClassicServer(rootDir, options)
Creates a Koa middleware for serving static files.
Parameters:
rootDir(String, required) — Absolute path to the directory containing filesoptions(Object, optional) — Configuration options
Returns: Koa middleware function
Options Summary
{
// HTTP methods allowed (default: ['GET'])
method: ['GET', 'HEAD'],
// Directory listing (V3 namespace)
dirListing: {
enabled: true,
maxEntries: 10000, // cap visible entries (0 = disabled)
entriesPerPage: 100, // entries per page (0 = disabled)
},
// Index file resolution (Array of strings and/or RegExp)
index: ['index.html', 'index.htm'],
// URL routing
urlPrefix: '/static',
urlsReserved: ['/api', '/admin'],
useOriginalUrl: true,
// Hidden files / dirs
hidden: {
dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
alwaysHide: [],
},
// Clean URLs
hideExtension: { ext: '.ejs', redirect: 301 },
// Browser HTTP caching
browserCacheEnabled: false,
browserCacheMaxAge: 3600,
// Template engine
template: {
ext: ['ejs'],
renderTimeout: 30000, // ms; 0 disables the cap
render: async (ctx, next, filePath, { signal }) => { /* ... */ },
},
// Observability
logger: console, // any { error, warn, info, debug } shape
}Options Details
| Option | Type | Default | Description |
|---|---|---|---|
| method | String[] | ['GET'] | Allowed HTTP methods |
| dirListing.enabled | Boolean | true | V3 Render directory listing HTML when no index file matches |
| dirListing.maxEntries | Number | 10000 | V3 Cap entries shown / sorted / stat'd (0 = disabled) |
| dirListing.entriesPerPage | Number | 100 | V3 Entries per listing page (0 = disabled) |
| index | Array | [] | Index file patterns (strings, RegExp, or mixed) |
| urlPrefix | String | '' | URL path prefix |
| urlsReserved | String[] | [] | First-level paths passed through to next middleware |
| useOriginalUrl | Boolean | true | Use ctx.originalUrl (true) or ctx.url (false) |
| hideExtension.ext | String | – | Extension to hide (.ejs, must start with .) |
| hideExtension.redirect | Number | 301 | HTTP redirect code |
| hidden.dotFiles.default | String | 'visible' | Default visibility for .foo files ('hidden' to harden) |
| hidden.dotFiles.whitelist | Array | [] | Names always visible (string/glob/RegExp) |
| hidden.dotFiles.blacklist | Array | [] | Names always hidden (overrides whitelist) |
| hidden.dotDirs.default | String | 'visible' | Default visibility for .foo directories |
| hidden.dotDirs.whitelist | Array | [] | Names always visible |
| hidden.dotDirs.blacklist | Array | [] | Names always hidden |
| hidden.alwaysHide | Array | [] | Path-aware patterns (string glob or RegExp) |
| browserCacheEnabled | Boolean | false | Emit ETag + Last-Modified (recommended true in production) |
| browserCacheMaxAge | Number | 3600 | Cache-Control: max-age in seconds |
| template.render | Function | – | async (ctx, next, filePath, { signal }) => void |
| template.ext | String[] | [] | Extensions handled by the template engine |
| template.renderTimeout | Number | 30000 | V3 Max render time in ms (0 = disabled) |
| logger | Object | console | V3 Logger with { error, warn, info, debug } |
For deep dives, see DOCUMENTATION.md and the per-option guides in docs/.
Directory Listing Features
Sortable Columns
Click any column header to sort:
- Name — Alphabetical (A→Z / Z→A)
- Type — By MIME type (directories first)
- Size — By byte size (directories first)
Visual indicators: ↑ ascending, ↓ descending. Sort + order are preserved across pagination links.
Pagination (V3)
When the number of visible entries exceeds dirListing.entriesPerPage, a numbered paginator is rendered below the table:
« First · ‹ Prev · 0 · 1 · … · 7 · 8 · 9 · Next › · Last »- Page index is 0-based (
?page=N). - Invalid or out-of-range values clamp silently.
- Response header
X-Dir-Pagination: <current>/<last>is emitted only when pagination is meaningful.
Truncation Banner (V3)
When dirListing.maxEntries is hit, a banner is rendered above the table and X-Dir-Truncated: <N> is set, so capped listings are visible both to users and to monitoring.
File Size Display
Human-readable: 1.5 KB, 2.3 MB, 1.2 GB. Directories show -.
Navigation
- Click folder → enter directory
- Click file → serve / download
- Parent Directory link → go up one level
Symlink Support
The middleware follows symbolic links transparently via fs.promises.stat() — useful in NixOS, Docker bind mounts, npm link, and Capistrano-style deploys.
| Entry type | Indicator | Clickable | Type column |
|---|---|---|---|
| Symlink to file | ( Symlink ) | yes | target MIME |
| Symlink to directory | ( Symlink ) | yes | DIR |
| Broken symlink | ( Broken Symlink ) | no | original MIME guess |
Regular files incur zero additional stat() overhead.
Security
Built-in Protection
1. Path Traversal
GET /../../etc/passwd → 403 Forbidden
GET /%2e%2e%2fpackage.json → 403 Forbidden
GET /file\0.txt → 400 Bad Request (null-byte guard)Defense in depth: null-byte rejection → path.normalize() → resolved-path boundary check against rootDir.
2. XSS in Directory Listing
All file and directory names are HTML-escaped. CSS is inlined under a hash-based Content-Security-Policy recomputed at module load — script execution from inline <style>/<script> is rejected by the browser.
3. Dot-Files Hidden by Default (V3)
.env, .git/config, SSH keys, etc. return 404 unless explicitly whitelisted via hidden.dotFiles.whitelist. The .well-known whitelist pattern stays friendly to ACME / Let's Encrypt.
4. Security Headers on Generated Pages
The middleware emits the following on directory listings and error pages (404/405/500/etc.):
| Header | Value |
|---|---|
| Content-Security-Policy | hash-based on listing, fully restrictive on errors |
| X-Content-Type-Options | nosniff |
| X-Frame-Options | DENY |
| Referrer-Policy | no-referrer |
| Permissions-Policy | camera=(), microphone=(), geolocation=(), payment=() |
⚠️ User-served static files (HTML/JS/CSS on disk) are returned without these headers — by design. See docs/DOCUMENTATION.md → Limiti dei Security Headers for an upstream-middleware example that applies your own CSP/HSTS to static files.
5. DNS Rebinding
The middleware does not validate the Host header — that belongs to the reverse proxy or an application-level allowlist. See docs/DOCUMENTATION.md → DNS Rebinding for nginx + Koa allowlist examples.
6. Reserved URLs
app.use(koaClassicServer(__dirname, {
urlsReserved: ['/admin', '/api', '/.git', '/node_modules'],
}));7. Race-Condition Protection
File metadata is verified before streaming. A file deleted between check and access returns 404, never a crash or partial response.
8. Bounded Listings (V3)
dirListing.maxEntries caps the number of entries that are sorted, stat'd, and rendered per listing — bounds CPU and HTML size against accidentally-large folders. The initial readdir() is not bounded by this option; an opt-in streaming mode for adversarial-directory workloads is planned for v3.1.
9. Template Render Timeout (V3)
template.renderTimeout (default 30 s) prevents a hung or runaway template render from blocking the request indefinitely; the AbortSignal forwarded to the renderer lets you abort downstream I/O cleanly.
See:
Design philosophy & Security Checklist
koa-classic-server follows the principle: "if a file is in rootDir, GET on its path returns it". The defaults serve files without applying surprise restrictions — the operator is the source of truth. See CLAUDE.md for the full design philosophy.
This means hardening is opt-in via explicit configuration. The checklist below covers the most common production concerns. Each item is one or two lines of configuration; not all of them apply to every deployment.
✅ Static site / public asset serving
- [ ] Hide dot-files that may contain secrets:
hidden: { dotFiles: { default: 'hidden', whitelist: ['.well-known'] } } - [ ] Block dot-directories like
.git:hidden: { dotDirs: { default: 'hidden', whitelist: ['.well-known'] } } - [ ] Disable directory listing in production:
dirListing: { enabled: false }(combine with anindexfile) - [ ] Enable browser HTTP caching:
browserCacheEnabled: true, browserCacheMaxAge: 86400 - [ ] Restrict methods to read-only (default already
['GET']):method: ['GET', 'HEAD'] - [ ] Reserve sensitive paths for app routes:
urlsReserved: ['/api', '/admin'] - [ ] Add upstream security headers for user-served HTML (not auto-added by this middleware — see DNS Rebinding / Headers in
docs/DOCUMENTATION.md).
✅ User uploads, multi-tenant, untrusted-write directories
- [ ] Lower the entry cap for accidentally-large dirs:
dirListing: { maxEntries: 1000 }(default 100000 is a safety net, not a security feature) - [ ] Hide dot-files at every depth:
hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'hidden' } } - [ ] Add path-aware blocklists for known secret patterns:
hidden: { alwaysHide: ['*.key', '*.pem', /\.secret$/, 'config/secrets/**'] } - [ ] Monitor directory growth externally (cron + alert) — the v3.0 cap bounds rendering CPU but not the initial
readdir()allocation. See[F-1]indocs/security_improvement_for_V3.mdfor the v3.1 streaming-read opt-in tracking this gap.
✅ Production hygiene (any deployment)
- [ ] Validate
Hostheader upstream (nginxserver_nameallowlist or app-level middleware) — this middleware does NOT validateHost. See DNS Rebinding indocs/DOCUMENTATION.md. - [ ] Disable template-engine in production if you don't use SSR — minimizes attack surface:
omit the
templateoption entirely - [ ] Tune
template.renderTimeoutif you do use SSR — default 30 s is conservative; tighten for tight-SLA services - [ ] Inject a real logger instead of
console:logger: pino()so security-relevant warnings reach your aggregation - [ ] Pin the latest patch version in
package.jsonand runnpm auditin CI
Suggested production security configuration
A single configuration block that covers most production deployments. Start here and tune for your workload (static site vs uploads vs internal admin):
const Koa = require('koa');
const pino = require('pino')({ level: 'info' });
const path = require('path');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
// 1) Validate Host header — mitigates DNS rebinding on LAN / loopback exposure.
const ALLOWED_HOSTS = new Set([
'app.example.com',
'localhost:3000',
]);
app.use(async (ctx, next) => {
if (!ALLOWED_HOSTS.has(ctx.host)) {
ctx.status = 421;
ctx.body = 'Misdirected Request';
return;
}
await next();
});
// 2) Apply security headers to user-served HTML/JS/CSS. The middleware
// sets these only on its own generated pages (listing + errors).
app.use(async (ctx, next) => {
ctx.set('X-Content-Type-Options', 'nosniff');
ctx.set('Referrer-Policy', 'strict-origin-when-cross-origin');
ctx.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
await next();
});
// 3) The file server with hardened defaults.
app.use(koaClassicServer(path.join(__dirname, 'public'), {
method: ['GET', 'HEAD'], // read-only
index: ['index.html'], // serve index when present
dirListing: {
enabled: process.env.NODE_ENV !== 'production',
maxEntries: 10000, // tighten the soft cap below the 100k default
entriesPerPage: 100,
},
hidden: {
dotFiles: {
default: 'hidden', // hide .env / .htaccess / etc by default
whitelist: ['.well-known'], // expose ACME / Let's Encrypt
},
dotDirs: {
default: 'hidden',
whitelist: ['.well-known'],
},
alwaysHide: ['*.key', '*.pem', /^backup-/, /\.secret$/],
},
browserCacheEnabled: true,
browserCacheMaxAge: 86400, // 24 h — bandwidth savings on cache hits
logger: pino, // pipe internal warnings to structured logs
urlsReserved: ['/api', '/admin'], // routes handled by other middleware
}));
app.listen(3000);For multi-tenant or user-upload scenarios, also drop dirListing.maxEntries to 1000 and monitor the served directory's size externally.
Performance
Optimizations
- Single-syscall
readdir()— directory entries fetched in one batched syscall, then sliced todirListing.maxEntriesto cap rendering work - Single
stat()per item — no double filesystem traversal - Array
.join()for listing HTML — significantly less GC pressure than+= - HTTP conditional responses — 304s with
If-None-Match/If-Modified-Since(when caching enabled) - File streaming — large files streamed via
fs.createReadStream, never buffered in full - Pre-computed CSP hash — SHA-256 of inline CSS hashed once at module load, not per request
Benchmarks
See docs/BENCHMARKS.md and docs/PERFORMANCE_COMPARISON.md for full benchmarks and methodology.
Testing
# Run all tests
npm test
# Run security tests only
npm run test:security
# Run performance benchmarks
npm run test:performanceCoverage:
- ✅ 532 tests passing across 20 suites
- ✅ Security (path traversal, XSS, race conditions, CSP, hidden-files)
- ✅ Directory listing (sorting, pagination, truncation cap, symlinks)
- ✅ Template engine (timeout, abort signal, error propagation, EJS integration)
- ✅ Logger injection (validation, custom logger, console default)
- ✅ Index option (arrays, RegExp, priority)
- ✅
hideExtension(clean URLs, redirects, conflicts, validation) - ✅ HTTP caching (ETag, Last-Modified, 304)
- ✅ Performance benchmarks
Complete Documentation
Core
- DOCUMENTATION.md — Full API reference and usage guide
- FLOW_DIAGRAM.md — Visual flow diagrams and execution paths
- CHANGELOG.md — Version history and release notes
Template Engine
- TEMPLATE_ENGINE_GUIDE.md — EJS, Pug, Handlebars, Nunjucks; AbortSignal + timeout patterns
Configuration
- INDEX_OPTION_PRIORITY.md — Priority rules for
index - EXAMPLES_INDEX_OPTION.md — 10 practical examples
Security
- security_improvement_for_V3.md — Audit roadmap and status
Performance
- PERFORMANCE_ANALYSIS.md — Optimization analysis
- PERFORMANCE_COMPARISON.md — Latency, throughput, concurrency
- OPTIMIZATION_HTTP_CACHING.md — Caching internals
- BENCHMARKS.md — Methodology and results
Code Quality
- CODE_REVIEW.md — Code review and standards
- DEBUG_REPORT.md — Known limitations and debugging
Migration Guide
From v2.x to v3.x
Breaking changes
| What | v2.x | v3.x |
|---|---|---|
| index: 'index.html' | accepted | throws — must be an array |
| cacheMaxAge | accepted | removed — use browserCacheMaxAge |
| enableCaching | accepted | removed — use browserCacheEnabled |
| showDirContents | accepted | accepted as deprecated alias — emits a one-time warning, prefer dirListing: { enabled: true } |
| Dot-files | served | served (unchanged — opt into hiding via hidden.dotFiles.default: 'hidden'; see Security Checklist) |
| Logger | console only | logger option injects any logger; default still console |
| Template render signature | (ctx, next, filePath) | (ctx, next, filePath, { signal }) — old signature still works, signal is opt-in |
Quick migration
// v2.x
app.use(koaClassicServer(root, {
index: 'index.html',
enableCaching: true,
cacheMaxAge: 3600,
showDirContents: true,
}));
// v3.x
app.use(koaClassicServer(root, {
index: ['index.html'],
browserCacheEnabled: true,
browserCacheMaxAge: 3600,
dirListing: { enabled: true },
}));Dot-files in v3
// To restore v2.x behavior (serve dot-files):
{ hidden: { dotFiles: { default: 'visible' } } }
// Recommended v3 — hide dot-files but expose .well-known for ACME / Let's Encrypt:
{
hidden: {
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
},
}Template render in v3
// v2.x — still works:
template: { render: async (ctx, next, filePath) => { /* ... */ } }
// v3.x — opt into the AbortSignal:
template: {
renderTimeout: 5000,
render: async (ctx, next, filePath, { signal }) => {
const data = await fetchData({ signal });
ctx.body = await ejs.renderFile(filePath, data, { signal });
ctx.type = 'text/html';
},
}From v1.x to v2.x
// v1.x
{ index: 'index.html' }
// v2.x+
{ index: ['index.html'] }See the full CHANGELOG.md for every change.
Examples
Example 1: Simple Static Server
const Koa = require('koa');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
app.use(koaClassicServer(__dirname + '/public'));
app.listen(3000);Example 2: Multi-Directory Server
const Koa = require('koa');
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
// Static assets — no listing in production
app.use(koaClassicServer(__dirname + '/public', {
urlPrefix: '/static',
dirListing: { enabled: false },
}));
// User uploads — paginated browsable index
app.use(koaClassicServer(__dirname + '/uploads', {
urlPrefix: '/files',
dirListing: {
enabled: true,
maxEntries: 5000,
entriesPerPage: 50,
},
}));
app.listen(3000);Example 3: Development Server with Templates + Timeout
const Koa = require('koa');
const koaClassicServer = require('koa-classic-server');
const ejs = require('ejs');
const app = new Koa();
app.use(koaClassicServer(__dirname + '/src', {
dirListing: { enabled: true },
template: {
ext: ['ejs'],
renderTimeout: 3000,
render: async (ctx, next, filePath, { signal }) => {
ctx.body = await ejs.renderFile(filePath, {
dev: true,
timestamp: Date.now(),
}, { signal });
ctx.type = 'text/html';
},
},
}));
app.listen(3000);Example 4: Production with Pino Logger + Caching
const Koa = require('koa');
const pino = require('pino')({ level: 'info' });
const koaClassicServer = require('koa-classic-server');
const app = new Koa();
app.use(koaClassicServer(__dirname + '/public', {
index: ['index.html'],
dirListing: { enabled: false },
browserCacheEnabled: true,
browserCacheMaxAge: 86400,
logger: pino,
}));
app.listen(3000);Troubleshooting
404 for all files
Use an absolute path for rootDir:
koaClassicServer('./public') // ❌ relative
koaClassicServer(__dirname + '/public') // ✅ absolute
koaClassicServer(path.join(__dirname, 'pub')) // ✅ absoluteReserved URLs not matching nested paths
urlsReserved only matches first-level path segments — use it for top-level routes (/api), not nested ones (/api/users).
Directory listing shows fewer files than expected
Check the response headers: X-Dir-Truncated indicates the dirListing.maxEntries cap was reached. Increase the cap or paginate via ?page=N.
Templates time out under load
Lower template.renderTimeout to fail fast, forward the signal to your I/O, and check the logger output for Template render timeout after Xms warnings.
See full troubleshooting: DEBUG_REPORT.md.
Contributing
Contributions are welcome:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass (
npm test) - Submit a pull request
Known Limitations
urlsReservedonly matches first-level path segments- The middleware does not validate the
Hostheader — configure a reverse proxy or an upstream allowlist (see DOCUMENTATION.md → DNS Rebinding) - Static files are returned without security headers — apply your own upstream middleware (see DOCUMENTATION.md → Limiti dei Security Headers)
See DEBUG_REPORT.md for technical details.
License
MIT License — see LICENSE file for details.
Author
Italo Paesano
Links
- npm Package — Official npm package
- GitHub Repository — Source code
- Issue Tracker — Report bugs
- Full Documentation — Complete reference
Changelog
See CHANGELOG.md for version history.
⚠️ Security Notice: Always use the latest version for security updates and bug fixes.
