Building a Real-World File Server with Node.js

Introduction to File Servers

Think of a file server as a digital librarian. Just as a librarian helps you find books, keeps them organized, and ensures they're delivered to you in good condition, a file server manages digital files, makes them accessible, and delivers them efficiently. Let's build a robust file server that handles various types of files and serves them appropriately.

Core Components of Our File Server


const http = require('http');
const fs = require('fs').promises;
const path = require('path');
const url = require('url');

// Like a library catalog system for digital files
const MIME_TYPES = {
    '.html': 'text/html',
    '.css': 'text/css',
    '.js': 'application/javascript',
    '.json': 'application/json',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.gif': 'image/gif',
    '.svg': 'image/svg+xml',
    '.ico': 'image/x-icon',
    '.pdf': 'application/pdf',
    '.zip': 'application/zip',
    '.doc': 'application/msword',
    '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    '.txt': 'text/plain'
};

// Configuration object - like library rules and policies
const SERVER_CONFIG = {
    port: 3000,
    rootDir: 'public',
    defaultIndex: 'index.html',
    cacheControl: 'public, max-age=86400',
    maxAge: 86400,
    compressionThreshold: 1024, // bytes
    allowedMethods: ['GET', 'HEAD'],
    corsOrigin: '*'
};
            

Request Handling and Security

Like a librarian checking visitor IDs and ensuring books are properly handled, we need to implement security measures and proper request handling:


class FileServer {
    constructor(config = SERVER_CONFIG) {
        this.config = config;
        this.server = null;
    }

    // Security check - like verifying a visitor's credentials
    validateRequest(req) {
        // Check if method is allowed
        if (!this.config.allowedMethods.includes(req.method)) {
            throw new Error('Method Not Allowed');
        }

        // Clean and validate the path (prevent directory traversal)
        const parsedUrl = url.parse(req.url);
        const sanitizedPath = path.normalize(parsedUrl.pathname)
            .replace(/^(\.\.[\/\\])+/, '');

        const fullPath = path.join(this.config.rootDir, sanitizedPath);

        // Ensure path stays within root directory
        if (!fullPath.startsWith(path.resolve(this.config.rootDir))) {
            throw new Error('Forbidden');
        }

        return fullPath;
    }

    // Handle errors - like dealing with missing or damaged books
    handleError(res, error) {
        const errorMessages = {
            'ENOENT': { status: 404, message: 'File not found' },
            'Forbidden': { status: 403, message: 'Access denied' },
            'Method Not Allowed': { status: 405, message: 'Method not allowed' }
        };

        const errorInfo = errorMessages[error.code || error.message] || 
                         { status: 500, message: 'Internal server error' };

        res.writeHead(errorInfo.status, {
            'Content-Type': 'application/json',
            'Cache-Control': 'no-cache'
        });

        res.end(JSON.stringify({
            error: errorInfo.message,
            status: errorInfo.status
        }));
    }
            

File Handling and Response Generation

Like a librarian retrieving and preparing books for delivery, we need to handle files and generate appropriate responses:


    // Get file stats - like checking a book's availability and condition
    async getFileStats(fullPath) {
        const stats = await fs.stat(fullPath);
        return {
            isDirectory: stats.isDirectory(),
            size: stats.size,
            mtime: stats.mtime,
            ctime: stats.ctime
        };
    }

    // Generate response headers - like preparing a book for checkout
    generateHeaders(filepath, stats) {
        const ext = path.extname(filepath);
        const contentType = MIME_TYPES[ext] || 'application/octet-stream';
        
        return {
            'Content-Type': contentType,
            'Content-Length': stats.size,
            'Last-Modified': stats.mtime.toUTCString(),
            'Cache-Control': this.config.cacheControl,
            'Accept-Ranges': 'bytes',
            'Access-Control-Allow-Origin': this.config.corsOrigin
        };
    }

    // Handle directory listing - like showing available books in a section
    async generateDirectoryListing(dirPath, requestPath) {
        const files = await fs.readdir(dirPath);
        const fileList = await Promise.all(
            files.map(async file => {
                const filePath = path.join(dirPath, file);
                const stats = await this.getFileStats(filePath);
                return {
                    name: file,
                    isDirectory: stats.isDirectory,
                    size: stats.size,
                    mtime: stats.mtime
                };
            })
        );

        const html = `
            
            
            
                Directory: ${requestPath}
                
            
            
                

Directory: ${requestPath}

${fileList.map(file => ` `).join('')}
Name Type Size Last Modified
${file.name} ${file.isDirectory ? 'Directory' : 'File'} ${file.isDirectory ? '-' : this.formatFileSize(file.size)} ${file.mtime.toLocaleString()}
`; return html; } // Format file size - like describing a book's dimensions formatFileSize(bytes) { const units = ['B', 'KB', 'MB', 'GB', 'TB']; let size = bytes; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return `${size.toFixed(2)} ${units[unitIndex]}`; }

Range Requests and Streaming

Like allowing readers to request specific chapters or pages, we need to handle partial content requests:


    // Handle range requests - like providing specific book pages
    handleRangeRequest(req, res, fullPath, stats) {
        const range = req.headers.range;
        if (!range) return false;

        const parts = range.replace(/bytes=/, '').split('-');
        const start = parseInt(parts[0], 10);
        const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1;

        if (start >= stats.size || end >= stats.size) {
            res.writeHead(416, {
                'Content-Range': `bytes */${stats.size}`
            });
            res.end();
            return true;
        }

        const headers = this.generateHeaders(fullPath, stats);
        headers['Content-Range'] = `bytes ${start}-${end}/${stats.size}`;
        headers['Content-Length'] = end - start + 1;
        headers['Accept-Ranges'] = 'bytes';

        res.writeHead(206, headers);
        const fileStream = fs.createReadStream(fullPath, { start, end });
        fileStream.pipe(res);
        return true;
    }
            

Main Request Handler

The main request handler orchestrates all components, like a head librarian coordinating different library services:


    async handleRequest(req, res) {
        try {
            const fullPath = this.validateRequest(req);
            const stats = await this.getFileStats(fullPath);

            // Handle directory requests
            if (stats.isDirectory) {
                // Check for index file
                const indexPath = path.join(fullPath, this.config.defaultIndex);
                try {
                    await fs.access(indexPath);
                    const indexStats = await this.getFileStats(indexPath);
                    await this.serveFile(req, res, indexPath, indexStats);
                    return;
                } catch (e) {
                    // No index file, show directory listing
                    const listing = await this.generateDirectoryListing(
                        fullPath, 
                        req.url
                    );
                    res.writeHead(200, {
                        'Content-Type': 'text/html',
                        'Content-Length': Buffer.byteLength(listing)
                    });
                    res.end(listing);
                    return;
                }
            }

            // Handle file requests
            await this.serveFile(req, res, fullPath, stats);

        } catch (error) {
            this.handleError(res, error);
        }
    }

    // Serve file content - like delivering a book to a reader
    async serveFile(req, res, fullPath, stats) {
        // Handle range requests
        if (this.handleRangeRequest(req, res, fullPath, stats)) {
            return;
        }

        // Regular file serving
        const headers = this.generateHeaders(fullPath, stats);
        res.writeHead(200, headers);

        // Stream the file
        const fileStream = fs.createReadStream(fullPath);
        fileStream.pipe(res);
    }

    // Start the server - like opening the library
    start() {
        this.server = http.createServer((req, res) => {
            this.handleRequest(req, res);
        });

        this.server.listen(this.config.port, () => {
            console.log(
                `File server running at http://localhost:${this.config.port}`
            );
        });
    }
}
            

Usage Example

Here's how to use our file server:


// Create and start the file server
const fileServer = new FileServer({
    ...SERVER_CONFIG,
    rootDir: 'public',
    port: 3000
});

fileServer.start();
            

Directory structure example:


public/
├── index.html
├── css/
│   └── styles.css
├── js/
│   └── main.js
├── images/
│   ├── photo.jpg
│   └── icon.png
└── documents/
    └── report.pdf
            

Further Topics to Explore

To enhance your file server implementation, consider exploring:

Compression - Like offering both hardcover and paperback versions

Caching strategies - Like managing frequently requested books

Authentication - Like managing library cards and memberships

Rate limiting - Like managing busy periods in the library

Logging and monitoring - Like keeping track of book checkouts

WebSocket integration - Like real-time updates on book availability