NextRush

@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 GET and HEAD requests
  • Returns index.html for directory requests
  • Redirects directory URLs without trailing slash (301)
  • Generates ETag and Last-Modified headers for conditional requests
  • Sets X-Content-Type-Options: nosniff to prevent MIME sniffing
  • Supports Range requests 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

PropertyTypeDescription
rootstringRoot directory to serve files from. Resolved to an absolute path.
prefix`/${string}` | ''= ''URL prefix to mount under.
indexstring | false= 'index.html'Default file for directory requests. Set to false to disable.
fallthroughboolean= falsePass unmatched requests to the next middleware instead of returning 404.
redirectboolean= trueRedirect directory requests without trailing slash (301).
maxAgenumber= 0Cache-Control max-age in seconds.
immutableboolean= falseAdd '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.
extensionsstring[]= []File extensions to try when a file is not found (e.g., ['.html', '.htm']).
etagboolean= trueGenerate ETag headers for conditional requests.
lastModifiedboolean= trueSend Last-Modified header.
acceptRangesboolean= trueEnable Accept-Ranges header for partial content requests.
setHeaders?(ctx: NodeContext, absolutePath: string, stat: StatsLike) => voidHook called before sending a file. Use to set custom response headers.
highWaterMarknumber= 1048576Files smaller than this (bytes) are read into memory. Larger files are streamed.
followSymlinksboolean= falseFollow symbolic links. When true, resolved path must remain within root.
xContentTypeOptionsboolean= trueSet X-Content-Type-Options: nosniff header.
streamTimeoutnumber= 30000Timeout 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.


On this page