Static Files in Node.js — No serve-static Needed
When Express serves your public/ folder, it uses a package called serve-static under the hood. That package reads files from disk and sends them with the right headers. We can do exactly the same thing with Node's built-in fs and path modules — and learn a lot about security in the process.
static.js middleware that reads files from a public/ directory, serves them with correct MIME types, adds caching headers using ETag and Cache-Control, and prevents directory traversal attacks.
Reading a File with fs.readFile
The most basic version — just read a file and send it:
const http = require('http');
const fs = require('fs');
const path = require('path');
http.createServer((req, res) => {
// Serve index.html for every request (for now)
const filePath = path.join(__dirname, 'public', 'index.html');
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(data);
});
}).listen(3000);
This works, but it uses the callback style. Let's use fs.promises for cleaner async/await code throughout:
const fs = require('fs').promises; // or: require('fs/promises') in Node 14+
async function serveFile(res, filePath, contentType) {
try {
const data = await fs.readFile(filePath);
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 — File not found');
}
}
MIME Types — Telling the Browser What You're Sending
Without the right Content-Type header, browsers won't know how to handle your files. CSS won't be applied, JavaScript won't execute. We need a MIME type map:
// mime.js
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2':'font/woff2',
'.txt': 'text/plain; charset=utf-8',
'.pdf': 'application/pdf',
};
function getMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
}
module.exports = { getMimeType };
Directory Traversal Attacks — The Critical Security Issue
This is the most important part of serving static files. A malicious user might request:
GET /../../etc/passwd HTTP/1.1
GET /../server.js HTTP/1.1
GET /%2e%2e%2fetc%2fpasswd HTTP/1.1 (URL-encoded)
If you naively join public/ + req.url, you'll serve files outside your intended directory. The fix is to resolve the path and verify it still starts with your public directory:
const path = require('path');
const PUBLIC_DIR = path.resolve(__dirname, 'public');
function getSafePath(requestUrl) {
// Decode URL encoding (%2e%2e → ..)
const decoded = decodeURIComponent(requestUrl);
// Remove query string
const pathname = decoded.split('?')[0];
// Join with public dir and resolve (normalizes .. and .)
const resolved = path.resolve(PUBLIC_DIR, '.' + pathname);
// CRITICAL: verify the resolved path is inside PUBLIC_DIR
if (!resolved.startsWith(PUBLIC_DIR + path.sep) && resolved !== PUBLIC_DIR) {
return null; // Traversal attempt detected
}
return resolved;
}
path.resolve() call handles all the .. normalization.
Building the Full Static File Server
Now let's put it all together into a reusable static.js module:
// static.js
'use strict';
const fs = require('fs').promises;
const path = require('path');
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.txt': 'text/plain',
};
function createStaticHandler(publicDir) {
const root = path.resolve(publicDir);
return async function staticHandler(req, res) {
// Only serve GET requests
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
// Decode and clean the URL path
let pathname;
try {
pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname);
} catch {
return false;
}
// Default to index.html for directory requests
if (pathname.endsWith('/')) pathname += 'index.html';
// Security: resolve and verify the path
const filePath = path.resolve(root, '.' + pathname);
if (!filePath.startsWith(root + path.sep) && filePath !== root) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('403 Forbidden');
return true;
}
// Read the file
let data, stat;
try {
[data, stat] = await Promise.all([
fs.readFile(filePath),
fs.stat(filePath),
]);
} catch {
return false; // File not found — let other routes handle it
}
// Build ETag from file size + modification time
const etag = `"${stat.size}-${stat.mtime.getTime()}"`;
// Cache validation — 304 Not Modified
if (req.headers['if-none-match'] === etag) {
res.writeHead(304);
res.end();
return true;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'ETag': etag,
'Cache-Control': 'public, max-age=3600', // 1 hour
'Last-Modified': stat.mtime.toUTCString(),
});
// HEAD requests: headers only, no body
if (req.method === 'HEAD') {
res.end();
} else {
res.end(data);
}
return true; // Handled
};
}
module.exports = { createStaticHandler };
Integrating with Our Router
// server.js
const http = require('http');
const Router = require('./router');
const { createStaticHandler } = require('./static');
const router = new Router();
const serveStatic = createStaticHandler('./public');
// API routes
router.get('/api/todos', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ todos: [] }));
});
const server = http.createServer(async (req, res) => {
// Try static files first
const handled = await serveStatic(req, res);
if (handled) return;
// Fall through to API router
router.handle(req, res);
});
server.listen(3000, () => console.log('http://localhost:3000'));
Understanding ETag and Caching
The ETag header is a fingerprint of a file's content. The browser caches it and sends If-None-Match: "etag-value" on the next request. If the ETag still matches, we return 304 Not Modified with no body — saving bandwidth.
// First request:
// Server → ETag: "1024-1710000000000"
// Browser caches the file and the ETag
// Second request (browser):
// Browser → If-None-Match: "1024-1710000000000"
// Server: ETag matches → 304 Not Modified (no body sent)
// Browser: uses cached version ✓
// After file changes:
// stat.mtime changes → new ETag → 200 OK + new file content
max-age=31536000, immutable for versioned assets (e.g., app.v2.js) and no-cache for HTML files that should always be validated. The ETag handles revalidation efficiently.
Streaming Large Files
For large files (images, PDFs), loading the entire file into memory with readFile is wasteful. Use streams instead:
const fs = require('fs'); // Note: NOT .promises — we need createReadStream
function streamFile(res, filePath, contentType, stat) {
const etag = `"${stat.size}-${stat.mtime.getTime()}"`;
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'ETag': etag,
'Cache-Control': 'public, max-age=86400',
});
// Stream directly from disk to response — no memory buffering
const stream = fs.createReadStream(filePath);
stream.pipe(res);
stream.on('error', () => {
res.destroy(); // Close connection on error
});
}
- Use
fs.promises.readFile()orcreateReadStream()for streaming - Always map file extensions to MIME types — browsers require them
- Use
path.resolve()+ prefix check to prevent directory traversal - ETag +
304 Not Modifiedsaves bandwidth for repeat visitors - Set
Cache-Controlheaders to let browsers cache assets
What's Next
In Post #4, we'll handle form submissions and POST requests — reading the request body chunk by chunk, parsing URL-encoded and JSON data, and implementing the redirect-after-POST pattern.