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

v5.3.1

Published

High-performance Koa middleware for serving static files with classic directory listing (similar, but not identical, to Apache 2), HTTP caching, template engine support, and comprehensive security fixes

Downloads

531

Readme

koa-classic-server

Serve a directory of files over HTTP from Koa — with classic, sortable directory listings, clean URLs, automatic compression, and HTTP caching. In the spirit of a traditional web server, but intentionally its own thing.

npm version License: MIT Tests Node Koa

One rule drives every default:

If a file exists under rootDir, GET on its path returns it. A directory without an index file shows a listing of every visible entry.

Defaults are transparent — the middleware never hides or restricts your files unless you ask. Hardening is opt-in.


Install

npm install koa-classic-server

Requires Node ≥ 20 and Koa ≥ 3.1.2. (Koa 2 and Node 18 were supported through v4.x; both were dropped in v5.0.0.)

Import

Ships as both CommonJS and ES modules via conditional exports — use whichever your project prefers:

// CommonJS
const koaClassicServer = require('koa-classic-server');

// ES modules
import koaClassicServer from 'koa-classic-server';

The examples below use CommonJS; they work identically with the ESM import.


Quick start

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')));
app.listen(3000);
// → serves ./public, with a browsable listing when there's no index file

rootDir must be an absolute path — use path.join(__dirname, ...).


Examples

Each example is self-contained. Copy, adjust the path, run.

Serve a site with an index file

app.use(koaClassicServer(path.join(__dirname, 'public'), {
  index: ['index.html'],   // GET /  → public/index.html
}));

A browsable, sortable, paginated listing

app.use(koaClassicServer(path.join(__dirname, 'uploads'), {
  dirListing: {
    enabled:        true,
    entriesPerPage: 100,   // paginate with ?page=N once a folder exceeds this
  },
}));
// Click Name / Type / Size to sort; sort order is kept across pages.

Clean URLs with a template engine (EJS)

const ejs = require('ejs');

app.use(koaClassicServer(path.join(__dirname, 'views'), {
  hideExtension: { ext: '.ejs' },   // GET /about → views/about.ejs ; GET /about.ejs → 301 /about
  template: {
    ext: ['.ejs'],   // leading dot optional since v5: '.ejs' (preferred) ≡ 'ejs'
    // signature: (ctx, next, filePath, rawBuffer, signal)
    render: async (ctx, next, filePath, rawBuffer, signal) => {
      ctx.type = 'html';
      ctx.body = await ejs.renderFile(filePath, { user: ctx.state.user }, { signal });
    },
  },
}));

rawBuffer (4th arg) is the file's bytes if already in cache (may be null) and is read-only. signal (5th arg) aborts on timeout and on client disconnect — forward it to your I/O.

HTTP caching (production)

app.use(koaClassicServer(path.join(__dirname, 'public'), {
  browserCacheEnabled: true,    // ETag + Last-Modified, and 304 on revalidation
  browserCacheMaxAge:  86400,   // Cache-Control: max-age=86400 (24h)
}));

Off by default (development-friendly). Conditional requests are fully handled: If-None-Match (lists, *, weak tags), If-Modified-Since, and Range (206) all behave per the HTTP spec.

Compression — automatic

Brotli/gzip are on by default for compressible types, negotiated from Accept-Encoding and cached server-side. Nothing to configure. To tune or disable:

app.use(koaClassicServer(root, {
  compression: {
    encodings:   ['br', 'gzip'], // server preference order; [] disables
    minFileSize: 1024,           // don't compress tiny files
  },
}));

// or turn it off entirely:
app.use(koaClassicServer(root, { compression: false }));

To size compression quality and the server-side caches to your host's RAM and CPU (small VPS, weak CPU, big dedicated box, behind a CDN), see the Performance Tuning Guide — it includes copy-paste profiles.

Hide sensitive files

Dot-files are served by default (the operator's directory is the source of truth). For a public deployment, hide them explicitly:

app.use(koaClassicServer(path.join(__dirname, 'www'), {
  hidden: {
    dotFiles: { default: 'hidden', whitelist: ['.well-known'] }, // keep ACME/Let's Encrypt working
    dotDirs:  { default: 'hidden', whitelist: ['.well-known'] },
    alwaysHide: ['*.key', /secret/i],                            // path-aware patterns
  },
}));
// .env, .git/config, *.key → 404

Custom error pages

Branded 404/500/504 pages from self-contained .html files (v4.2+). Keep them outside rootDir so they are not themselves reachable via URL; edit them anytime — changes are picked up without a restart, and an unreadable file falls back to the built-in page:

app.use(koaClassicServer(path.join(__dirname, 'www'), {
  errorPages: {
    404: path.join(__dirname, 'errors', '404.html'),
    500: path.join(__dirname, 'errors', '500.html'),
  },
}));

One rule: each page must be a single self-contained file (inline CSS, no external css/js/img references). Custom pages are served without the built-in pages' Content-Security-Policy, so an external reference would really load.

Mount under a prefix, pass through some routes

app.use(koaClassicServer(path.join(__dirname, 'public'), {
  urlPrefix:    '/static',              // GET /static/app.js → public/app.js
  urlsReserved: ['/api', '/admin'],     // first-level paths handed to the next middleware
}));

Serve several directories

Mount it once per directory, each under its own urlPrefix — a request outside a mount's prefix falls through to the next:

// Assets under /static, no listing
app.use(koaClassicServer(path.join(__dirname, 'public'), {
  urlPrefix: '/static',
  dirListing: { enabled: false },
}));

// Uploads under /files, browsable
app.use(koaClassicServer(path.join(__dirname, 'uploads'), {
  urlPrefix: '/files',
  dirListing: { enabled: true },
}));

Allow more methods

GET and HEAD are accepted by default — HEAD needs no opt-in, since RFC 9110 §9.1 makes the pair the minimum a general-purpose server must support. Any other verb falls through to the next middleware (it is not answered with 405, so a downstream router can handle it):

app.use(koaClassicServer(root, {
  method: ['GET', 'HEAD', 'POST'],   // POST served exactly like GET
}));

Dropping HEAD (method: ['GET']) is honored, but produces an RFC 9110 §9.1 non-conformant server: HEAD then answers 404 on files GET serves with 200. See the Security Hardening Guide §3.7.

Behind a URL rewriter (i18n, routing)

When an upstream middleware mutates ctx.url, tell the server to resolve against the rewritten URL:

app.use(async (ctx, next) => {
  if (ctx.path.startsWith('/it/')) ctx.url = ctx.url.replace(/^\/it/, ''); // /it/page → /page
  await next();
});

app.use(koaClassicServer(path.join(__dirname, 'www'), {
  useOriginalUrl: false,   // resolve ctx.url (rewritten) instead of ctx.originalUrl
}));

Keep symlinks inside rootDir

If rootDir holds files writable by untrusted parties (uploads, multi-tenant), stop a planted symlink from escaping the served tree:

app.use(koaClassicServer(root, {
  symlinks: 'follow-within-root',   // a link resolving outside rootDir → 404
}));

A production-shaped setup

const pino = require('pino')({ level: 'info' });

app.use(koaClassicServer(path.join(__dirname, 'public'), {
  index:               ['index.html'],
  dirListing:          { enabled: process.env.NODE_ENV !== 'production' },
  browserCacheEnabled: true,
  browserCacheMaxAge:  86400,
  logger:              pino,   // any { error, warn, info, debug } object (Pino/Winston/Bunyan/console)
  hidden: {
    dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
    dotDirs:  { default: 'hidden', whitelist: ['.well-known'] },
  },
}));

What's new in v5

v5.0.0 is the configuration-correctness release: config mistakes that used to misbehave silently at request time now fail fast at startup, and the two extension options finally share one coherent, forgiving syntax.

  • hideExtension.redirect is validated at startup. Accepted values are the real redirect codes — 300, 301, 302, 303, 305, 307, 308 — and anything else throws at factory time with the valid list in the message. Until v4 a wrong value failed silently in production: a non-redirect integer (200, 404, 999, …) was quietly sent as 302, and a non-integer value produced a 500 on every redirect.
  • The leading dot is optional in template.ext and hideExtension.ext'.ejs' and 'ejs' are equivalent everywhere; the preferred, documented form is '.ejs'. This also fixes a long-standing trap: a dotted template.ext entry (['.ejs']) used to never match, so the render never ran and the template source was served raw. It now works as intended.
  • template.ext matches by suffix (as hideExtension.ext always did), so compound extensions are supported: ['.html.ejs'] targets only *.html.ejs files. Entries that cannot name a suffix (empty, '.', non-string) are dropped with a one-time warning.
  • A non-function template.render now warns (one-time) instead of silently disabling template rendering.

The v4 highlights — canonical trailing slash, bounded compression with streamed-output caching, configurable compression quality, custom error pages — are all still here. Full details in the changelog.


Options

Defaults shown; every option is optional.

koaClassicServer(rootDir, {
  method: ['GET', 'HEAD'],               // allowed HTTP methods (upper-cased; other verbs → next())

  dirListing: {
    enabled:        true,                // render a listing when no index matches (false → 404)
    entriesPerPage: 100,                 // paginate above this (0 = off)
    maxEntries:     10000,               // safety-net cap on entries rendered (0 = off)
    trailingSlash:  true,                // v4: /dir → 301 /dir/, /file/ → 404
  },

  index: [],                             // e.g. ['index.html']; strings and/or RegExp

  urlPrefix:    '',                      // e.g. '/static' (leading slash, no trailing)
  urlsReserved: [],                      // e.g. ['/api'] — first-level, passed to next()
  useOriginalUrl: true,                  // false when an upstream middleware rewrites ctx.url

  hideExtension: {                       // clean URLs
    ext:      '.ejs',                    // suffix to hide — leading dot optional (v5), compound ('.tar.gz') ok
    redirect: 301,                       // v5: must be 300|301|302|303|305|307|308, else throws at startup
  },

  hidden: {                              // everything visible by default
    dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
    dotDirs:  { default: 'visible', whitelist: [], blacklist: [] },
    alwaysHide: [],                      // glob strings or RegExp, path-aware
  },

  browserCacheEnabled: false,            // ETag/Last-Modified/304 (recommended true in prod)
  browserCacheMaxAge:  3600,             // Cache-Control max-age (seconds)

  compression: {                         // or `compression: false`
    enabled:     true,
    encodings:   ['br', 'gzip'],
    minFileSize: 1024,
    maxFileSize: 10485760,               // 10 MB buffered-path cap (false = no cap)
    mimeTypes:   [],                     // override the compressible-type list
    buffered:  { brotliQuality: 11, gzipLevel: 9 },  // v4.3: quality when compressing once, then caching
    streaming: { brotliQuality: 4,  gzipLevel: 6 },  // v4.3: quality when compressing per request (above maxFileSize)
  },

  serverCache: {                         // in-memory server-side caches
    rawFile: {                           // cache raw file buffers (skip disk reads)
      enabled:      false,
      maxSize:      52428800,            // total RAM cap, bytes (50 MB)
      maxFileSize:  1048576,             // larger files are never cached, bytes (1 MB)
      maxAge:       0,                   // ms before an entry counts as stale (0 = off)
      warnInterval: 60000,               // ms between "maxSize reached" warnings (0 = always, false = never)
    },
    compressedFile: {                    // cache br/gzip responses (compress once)
      enabled:      true,
      maxSize:      104857600,           // total RAM cap, bytes (100 MB)
      maxEntrySize: undefined,           // v4.3: per-entry cap on the compressed output, bytes
                                         //   default: maxSize / 4; false = no per-entry cap;
                                         //   oversized entries are served, just never cached
      maxAge:       0,                   // ms before an entry counts as stale (0 = off)
      warnInterval: 60000,               // ms between "maxSize reached" warnings (0 = always, false = never)
    },
  },

  symlinks: 'follow',                    // 'follow' | 'follow-within-root' | 'deny'
  staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses

  errorPages: {                          // custom error pages (v4.2+); omitted → built-ins
    // 404: './errors/404.html',         // supported statuses: 404, 500, 504
    // 500: './errors/500.html',         // self-contained .html (inline CSS, no external refs)
    // 504: './errors/504.html',         // editable without a restart; unreadable → built-in
  },

  template: {
    ext: [],                             // e.g. ['.ejs'] — dot optional (v5); suffix match, so
                                         //   compound entries ('.html.ejs') target *.html.ejs only
    renderTimeout: 30000,                // ms; on timeout the request fails closed (0 = off)
    render: async (ctx, next, filePath, rawBuffer, signal) => { /* ... */ },
  },

  logger: console,                       // { error, warn, info, debug }
})

Full per-option reference: docs/DOCUMENTATION.md.


Security

Defaults are transparent, so hardening is a deliberate opt-in. The single source of truth is the Security Hardening Guide — threat models, per-topic recommendations, per-profile checklists, and a copy-paste maximally-hardened config.

Built in and always on: path-traversal defense (traversal / encoded / null-byte / boundary → 404 or 400, never a leak), HTML-escaping + hash-based CSP on the listing, security headers on generated pages, and last-resort error containment. Host validation and static-file CSP/HSTS are intentionally left to a reverse proxy or an upstream middleware.


Migrating from v4

If your hideExtension.redirect is already a real redirect code (or unset) and your template.ext entries are dot-less, v5 changes nothing — everything below is either a config bug surfacing earlier or a previously broken form starting to work.

| What | v4 | v5 | |---|---|---| | hideExtension.redirect: 200 (any non-redirect integer) | silently sent as 302 | throws at startup — valid: 300, 301, 302, 303, 305, 307, 308 | | hideExtension.redirect: '301' (non-integer) | 500 on every redirect | throws at startup | | template.ext: ['.ejs'] (with dot) | never matched — template source served raw | renders'.ejs''ejs', dotted form preferred | | template.ext: ['.html.ejs'] (compound) | never matched | targets *.html.ejs files only | | hideExtension.ext: 'ejs' (no dot) | worked, with a warning | works — warning gone, both forms legal | | template.render: <non-function> | silently dropped (sources served raw) | one-time warning (startup error planned for v6) |

The v3 → v4 notes (trailing slash, compression.maxFileSize, strict options validation) remain in the changelog.


Testing

npm test                 # full suite (lints first)
npm run test:ci          # suite without benchmarks — 1284 tests
npm run test:security    # security tests only
npm run test:performance # benchmarks

The suite includes fast-check property-based tests (__tests__/*.property.test.js) alongside the example-based ones. They are intentionally un-seeded; to replay a failure, see property-based-testing.md.


Docs


License

MIT © Italo Paesano · npm · GitHub · Issues