npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.

npm version License: MIT Tests Node


🎉 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.mddirListing 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 renderingdirListing.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 listingsdirListing.entriesPerPage adds 0-based ?page=N navigation with First/Prev/Next/Last + X-Dir-Pagination header ✅ Template render timeout + AbortSignaltemplate.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 pagesCSP, 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

See full changelog →


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 ListingsdirListing.maxEntries cap + dirListing.entriesPerPage navigation
  • ⏱️ 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-server

Requirements:

  • 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.png

4. 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 to dirListing.maxEntries — sorting, stat'ing, and rendering are CPU-bounded by dirListing.maxEntries. The initial readdir() 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: 10000 so monitoring can flag capped pages.
  • Pagination is rendered below the table with « First · ‹ Prev · 0 1 … N · Next › · Last », and an X-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. Active sort / order query 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 logger is omitted, behavior is unchanged (uses console).
  • 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 /about

10. 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 files
  • options (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 an index file)
  • [ ] 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] in docs/security_improvement_for_V3.md for the v3.1 streaming-read opt-in tracking this gap.

✅ Production hygiene (any deployment)

  • [ ] Validate Host header upstream (nginx server_name allowlist or app-level middleware) — this middleware does NOT validate Host. See DNS Rebinding in docs/DOCUMENTATION.md.
  • [ ] Disable template-engine in production if you don't use SSR — minimizes attack surface: omit the template option entirely
  • [ ] Tune template.renderTimeout if 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.json and run npm audit in 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 to dirListing.maxEntries to 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:performance

Coverage:

  • ✅ 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

Template Engine

Configuration

Security

Performance

Code Quality


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'))  // ✅ absolute

Reserved 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:

  1. Fork the repository
  2. Create a feature branch
  3. Add tests for new functionality
  4. Ensure all tests pass (npm test)
  5. Submit a pull request

Known Limitations

See DEBUG_REPORT.md for technical details.


License

MIT License — see LICENSE file for details.


Author

Italo Paesano


Links


Changelog

See CHANGELOG.md for version history.


⚠️ Security Notice: Always use the latest version for security updates and bug fixes.