Library / SDK
cloudhead/node-static avatar
cloudhead/node-static

node-static: a static file module that refuses to ship below full coverage

rfc 2616 compliant HTTP static-file server module, with built-in caching.

2,155 stars241 forksJavaScriptMIT

At a glance

What is it?
This is a small MIT licensed module for serving files over HTTP with conditional requests and a built-in cache, and its configuration surface is the interesting part: per-path cache durations keyed by glob, pre-compressed sibling files rather than on-the-fly compression, and a transform hook that returns a stream. It also enforces one hundred percent coverage on four metrics.
Who is it for?
node-static is worth using if you need a file server inside a Node application rather than a separate process in front of one, and if you specifically want to hand error handling back to your own routing logic instead of having a library decide what a missing file should return. It is a poor fit if you want compression, since it serves pre-built compressed siblings and never compresses anything itself, so you need a build step that produces them.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 102 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.

Editorial analysis

A module this size holding a hundred percent coverage bar

Before looking at any feature, the manifest contains a coverage configuration with four thresholds, all set to the same number: branches, functions, lines and statements. Coverage checking is enabled rather than merely reported, and the only exclusions are the build output directory and the test directory itself.

One hundred percent on all four is a bar most projects talk themselves out of within a year, and the usual arguments against it are all true in general: it pushes people toward trivial tests, it makes refactoring expensive, and it encourages unreachable defensive code just to satisfy the counter. Those arguments are about large codebases with unclear boundaries.

This is a small module with a narrow job. It serves a file, or it does not. Every branch in it corresponds to a decision somebody made about content types, cache headers, range requests or compression negotiation, and each of those is a thing you would want a test for anyway. At this size the coverage number is not a proxy for quality, it is a checklist that happens to be enforced by a tool.

The real payoff shows up in the one place a module this old is most likely to be wrong: the version number. The package is at zero point eight, has been for a while, and a 0.8 series on a small surface is exactly the kind of thing that accumulates small behavioural changes. Full coverage means those changes were forced through a test, and combined with a change log at the root, a user upgrading has a reasonable chance of knowing what moved.

What the coverage does not tell you is whether the tests are good. It cannot. It tells you every line ran, which is a precondition for the tests meaning anything, and nothing more. A module with one hundred percent coverage and a test that asserts the status code is not a well tested module, it is a well executed one.

Installation is one command:

sh
npm install node-static

The rest of the tooling is unremarkable and current: a flat linter configuration on the current major, a bundler for the second output format, a separate production type build configuration, an editor configuration, a change log and a benchmark directory. Integration tests run in parallel under a well-known assertion library, with a separate script for the compression path, which suggests the compression negotiation is tested in isolation because it is the part most likely to be fiddly.

The exports map is the whole dual-package story

The module presents itself two ways, and the manifest is where that is decided rather than guessed at.

The package declares itself as a module, and its main entry is the source-shaped module file. Then there is an exports map with three conditions on the root entry: type definitions pointing at a file in the build output, an import condition pointing at the same source-shaped file the main field names, and a require condition pointing at a CommonJS file that only exists after a build. A bundler produces that second file, and a separate type build produces the declarations.

That is a correctly configured modern dual package, and it is worth naming the three things it gets right. The declarations come from a build rather than from the source, so a consumer's editor does not have to resolve a TypeScript compiler. The require path points at a build artefact rather than at the source, so a CommonJS consumer does not get an untranspiled module. And the import path points at the real source, so the bundler for a modern consumer is not given a second copy of the same code.

The readme documents the consumer side of this in two short sections, one for module syntax with named imports of the server class, the version and the MIME lookup, and one for the older require form. The named export of a MIME lookup alongside the server class is a small sign that the library does not hide its content type table, which is exactly what you want when you need to override it.

The example directory carries two files, one for each module system, which is the detail that makes this verifiable. A dual package that is documented but not demonstrated is a dual package that is broken for half its users, because the failure is usually a resolution error that only appears under a real bundler. Shipping one working example per format costs almost nothing and proves the map.

One small blemish is worth noting because it is the kind of thing that outlives a migration: the repository URL in the manifest is written with an unencrypted scheme. For a package that is installed by every project that uses it, that field is metadata rather than transport, so nothing breaks, but it is the sort of leftover that a modernising pass would catch and a decade-old manifest accumulates.

Passing a callback silently transfers ownership of the error path

There is one behaviour in this module that deserves to be read twice, because it is correct, it is documented, and it will still surprise you.

The serve method takes an optional callback as its last argument, and the readme explains that it fires both on success and on failure. The error-handling example then logs the failure and writes the status and headers from the error object to the response. And then comes the sentence that matters: if you pass a callback and there is an error, the module will not respond to the client. The stated purpose is to give you the opportunity to re-route the request or handle it differently, and the example given is a static request for a file that does not exist being handed to an application instead.

So the callback is not an observation hook. It is a transfer of ownership. Add one for logging and you have also taken responsibility for writing a response on every failure path, and any path you forget becomes a request that hangs until the client times out. That is a much worse failure than a default 404, and it is invisible in testing unless your tests assert on the response body rather than on the absence of a crash.

The module offers a second mechanism and, to its credit, the readme recommends it for the logging case. The serve method returns the stream, and you can attach an error listener to it. With that approach the readme says explicitly that you do not have to send a response yourself, because the module still handles the failure. The difference between the two is not stylistic: one gives you the response, the other gives you the notification.

This is a real design decision rather than an oversight. Many frameworks make every failure your problem so that custom error pages are possible. This one makes it opt-in, and the default behaviour of a plain call to serve is that a missing file becomes a proper not found response with no work from you. The cost of that choice is a footgun, and the readme mitigates it with exactly one sentence in the right place.

Compression is a build step this module refuses to do for you

The compression option is off by default, and when you turn it on the module does not compress anything. That is the whole design, and the documentation is unusually clear about it.

Turning the option on makes the module look for a file with the same name plus a compressed extension, in the same folder as the original. If that sibling file exists, and if the client has said it accepts compressed transfers, the contents of the sibling are sent instead of the original, along with a header telling the client the payload is compressed.

So compression happens before the server sees the data, presumably in your build, and the server's job is limited to negotiation and delivery. That is a better division of labour than the alternative for a small module, for three reasons. The module does not need a compression library, which is why its runtime dependency list is five entries and does not include one. The same pre-built artefact works behind every server in a deployment, so you compress once rather than per request. And the behaviour is completely predictable, because it is a file lookup with no threshold, no content type heuristics and no fallback to the uncompressed file when the client refuses.

The option takes either a boolean or a regular expression, and the example given is a pattern that matches paths beginning with a text prefix. So you can apply it globally or only to paths that are worth compressing, which matters when you have a directory of already-compressed assets. The documentation notes that the boolean enables it and shows the pattern form as the selective case, and it also says that if the compressed file is not found the original is served, which is the sensible fallback and is stated rather than assumed.

The one thing to be careful about is that the two file types now have to be kept in sync. A build that regenerates a script but forgets the compressed sibling will serve the old one, and nothing in the module can detect that. That is a real operational hazard of the design, and it is a hazard the module has chosen to accept in exchange for not owning a compression implementation.

Cache duration as a glob map, which is unusual and correct

The cache option takes three different shapes, and the third is the reason this module is worth reading.

A number sets one duration for everything, in seconds, and that is the default. Passing a boolean instead of a number disables the header entirely, which is the setting you want when something else in front of the module is setting caching, and it is explicitly supported rather than being a side effect of passing something falsy.

The third shape is an object whose keys are glob patterns and whose values are durations. The documented example sets a five minute lifetime for all files matching a pattern that targets style sheets. A number would set one duration for every file served; a glob map lets a document and its assets have different lifetimes, which is the situation almost every real deployment is in, because you want a short lifetime on a document that references hashed assets and a long one on those assets.

The glob matching is delegated to a small, widely used matching library, which is the correct call rather than a shortcut, because glob semantics are subtle and a home-grown pattern matcher is a source of bugs you would spend an afternoon on. The same library appears in the runtime dependencies, and it is one of only five.

A module this small serving files for a development server is not a deployment, and the one hour default reflects that. For anything facing real traffic, the glob form is the setting that matters, and it is worth noticing that the readme gives a single example rather than a discussion of cache invalidation. The honest summary is that this module sets a header and nothing else: there is no cache store, no purging, no revalidation of its own. Conditional requests are handled, which is the other half of the story, but if you were expecting the module to manage a cache, it does not and was never going to.

Every example waits for the request to end first

One detail appears in every single usage example in the readme, and it is the only thing in the API documentation that is not optional.

In each case the request object gets an end listener attached, the serving happens inside that listener, and the stream is then resumed. The pattern is consistent across the basic example, the directory example, the custom file example, the not-found rerouting example and the error interception example. Five independent code samples, one shape.

The reason is the module's contract. It takes a request and a response and streams a file, and to do that correctly it needs the request to be complete, which is a stronger requirement than merely having a method and a URL available on the incoming message object. A file server that started streaming a body before it had finished reading a request with a body could interleave the two in ways the client does not expect, and a module that promises conditional request support has to read the conditional headers off a request it has fully received.

So the pattern is not a stylistic wart to be cleaned up. It is the module telling you what it needs, and the fact that it is repeated in every example rather than stated once in prose is a documentation choice, and a defensible one. Copy-pasteable code that includes the requirement is more reliable than a paragraph describing it, because nobody reads the paragraph.

Two smaller things in the same examples are worth noting. The directory hook takes a path, a request and a response and is expected to write a response and end it, which makes it the escape hatch for a virtual route that has no file behind it, and the example shows a generated greeting while noting in a comment that a real directory listing could go there instead. And the transform hook receives the file contents along with the path, request and response, and returns a stream transform rather than a string, which is the difference between a hook that can change encoding mid-file and one that cannot.

Editorial conclusion

node-static is worth using if you need a file server inside a Node application rather than a separate process in front of one, and if you specifically want to hand error handling back to your own routing logic instead of having a library decide what a missing file should return. It is a poor fit if you want compression, since it serves pre-built compressed siblings and never compresses anything itself, so you need a build step that produces them. Read the note about the error callback before you pass one, because adding a callback silently changes who is responsible for responding, and pin the version deliberately, since there are no releases and the change log is the only record of what changed in a 0.8 series module.

Frequently asked questions

What does node-static do?

It serves files over HTTP from Node, and is described as compliant with the HTTP specification. It supports conditional requests and head requests, sets cache headers, and can serve pre-compressed sibling files in place of originals.

How do I set different cache durations for different file types in node-static?

The cache option accepts an object whose keys are glob patterns and whose values are durations in seconds, so a pattern targeting style sheets with a short duration and a number for everything else works together. Passing a number sets one duration for all files and passing a boolean disables the cache header entirely.

What happens if I pass a callback to the serve method in node-static and an error occurs?

The module will not respond to the client. The readme states this explicitly, and the point is to let you re-route the request, for example sending a missing static file to an application instead. If you only want to be notified, attach an error listener to the returned stream instead, and the module still handles the failure itself.

Does node-static compress files on the fly?

No. The gzip option makes it look for a file with the same name plus a compressed extension in the same folder, and serve that instead when the client accepts compressed transfers, adding the encoding header. Compression therefore has to happen in your build, and the module only handles negotiation and delivery.

How does node-static ship as both an ES module and CommonJS?

The manifest sets the package as a module, names the source-shaped file as both the main entry and the import condition, points the require condition at a CommonJS file produced by a bundler during the build, and serves type declarations from a separate production type build. The example directory includes one working file per module system.

Official sources

  1. cloudhead/node-static on GitHub
  2. Issues
  3. License: MIT
  4. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/cloudhead-node-static.svg)](https://hysenlabs.com/projects/cloudhead-node-static)