@nextrush/static
Static file serving middleware with caching, range requests, and path traversal protection.
Every web application serves static assets — stylesheets, scripts, images, fonts. Doing it wrong means path traversal vulnerabilities, broken caching, or unnecessary re-downloads. This middleware handles file serving with built-in security, conditional caching, and range request support.
Default Behavior
With only root set and otherwise default options, the middleware:
- Serves files from the specified directory on
GETandHEADrequests - Returns
index.htmlfor directory requests - Redirects directory URLs without trailing slash (301)
- Generates
ETagandLast-Modifiedheaders for conditional requests - Sets
X-Content-Type-Options: nosniffto prevent MIME sniffing - Supports
Rangerequests for media streaming - Blocks path traversal, null bytes, double slashes, and symlinks
- Returns 404 for dotfiles (files starting with
.) - Reads small files (< 1 MB) into memory; streams larger files with a 30-second timeout
Installation
$ pnpm add @nextrush/static
Minimal Usage
import { createApp } from '@nextrush/core';
import { serveStatic } from '@nextrush/static';
const app = createApp();
app.use(serveStatic({ root: './public' }));./public/style.css becomes accessible at /style.css.
Configuration Options
StaticOptions
| Property | Type | Description |
|---|---|---|
root | string | Root directory to serve files from. Resolved to an absolute path. |
prefix | `/${string}` | ''= '' | URL prefix to mount under. |
index | string | false= 'index.html' | Default file for directory requests. Set to false to disable. |
fallthrough | boolean= false | Pass unmatched requests to the next middleware instead of returning 404. |
redirect | boolean= true | Redirect directory requests without trailing slash (301). |
maxAge | number= 0 | Cache-Control max-age in seconds. |
immutable | boolean= false | Add 'immutable' directive to Cache-Control. Only applies when maxAge > 0. |
dotfiles | 'ignore' | 'deny' | 'allow'= 'ignore' | How to handle dotfiles. 'ignore' returns 404, 'deny' returns 403, 'allow' serves them. |
extensions | string[]= [] | File extensions to try when a file is not found (e.g., ['.html', '.htm']). |
etag | boolean= true | Generate ETag headers for conditional requests. |
lastModified | boolean= true | Send Last-Modified header. |
acceptRanges | boolean= true | Enable Accept-Ranges header for partial content requests. |
setHeaders? | (ctx: NodeContext, absolutePath: string, stat: StatsLike) => void | Hook called before sending a file. Use to set custom response headers. |
highWaterMark | number= 1048576 | Files smaller than this (bytes) are read into memory. Larger files are streamed. |
followSymlinks | boolean= false | Follow symbolic links. When true, resolved path must remain within root. |
xContentTypeOptions | boolean= true | Set X-Content-Type-Options: nosniff header. |
streamTimeout | number= 30000 | Timeout for streaming operations in milliseconds. Set to 0 to disable. |
Integration Examples
Caching for Production
// Fingerprinted assets — long cache
app.use(
serveStatic({
root: './dist/assets',
prefix: '/assets',
maxAge: 31536000, // 1 year
immutable: true,
})
);
// Regular files — short cache
app.use(
serveStatic({
root: './public',
maxAge: 3600, // 1 hour
})
);SPA Fallback
import { createSendFile, serveStatic } from '@nextrush/static';
const sendIndex = createSendFile({ root: './dist' });
// Serve static assets, let 404s fall through
app.use(serveStatic({ root: './dist', fallthrough: true }));
// Fallback to index.html for client-side routing
app.use(async (ctx) => {
if (!ctx.path.startsWith('/api')) {
await sendIndex(ctx, 'index.html');
}
});Custom Headers
app.use(
serveStatic({
root: './public',
setHeaders: (ctx, absolutePath, stat) => {
if (absolutePath.endsWith('.pdf')) {
ctx.set('Content-Disposition', 'attachment');
}
},
})
);Sending Individual Files
Use createSendFile to serve files from route handlers:
import { createSendFile } from '@nextrush/static';
const sendPublicFile = createSendFile({ root: './public' });
app.get('/download/:file', async (ctx) => {
const sent = await sendPublicFile(ctx, ctx.params.file);
if (!sent) {
ctx.status = 404;
ctx.json({ error: 'File not found' });
}
});createSendFile returns false if the file does not exist, is a directory, or is a blocked dotfile.
Security
Built-in Path Traversal Protection
Path traversal attacks (../, null bytes, encoded variants) are blocked before any file system
access. Symlinks return 404 by default.
Path traversal, null bytes, double slashes, and encoded variants are blocked before any file system access. Symbolic links return 404 by default. When followSymlinks is true, the resolved path is verified to remain within the root directory.
X-Content-Type-Options: nosniff prevents browsers from MIME-sniffing responses. Range request parsing rejects values beyond Number.MAX_SAFE_INTEGER and only supports single ranges.
Streaming operations time out after 30 seconds by default, preventing slow-client attacks from holding connections open.
Common Mistakes
Passing a relative root without understanding resolution. The root value is resolved with path.resolve() at middleware creation time based on the working directory. Use an absolute path or import.meta.dirname to avoid surprises.
Using sendFile directly instead of createSendFile. The exported sendFile function requires a NormalizedStaticOptions object with all fields populated. Use createSendFile for route-level file serving — it handles normalization internally.
Setting immutable: true without maxAge. The immutable directive only applies when maxAge > 0. Without maxAge, browsers ignore it.
Enabling followSymlinks in untrusted directories. Symlink resolution validates the target stays within root, but if an attacker can create symlinks inside your static directory, they could link to other files within that tree.
Troubleshooting
Files return 404 but exist on disk. Check if the file is a dotfile (name starts with .). The default dotfiles: 'ignore' policy returns 404. Set dotfiles: 'allow' for files like .well-known/.
304 responses never happen. Verify that etag and lastModified are not set to false. The client must send If-None-Match or If-Modified-Since headers.
Large file downloads hang. The default streamTimeout of 30 seconds may be too short for very large files on slow connections. Increase streamTimeout or set it to 0 to disable.
/about does not resolve to /about.html. Set extensions: ['.html'] to enable extension fallback.
Related
- Plugins Overview — All plugin packages
- @nextrush/compression — Response compression
- @nextrush/helmet — Security headers