Simple File Server with Node.js
This section explores how to create a basic HTTP server in Node.js that provides remote access to files on the server's file system. Such a server can be used for various purposes, like enabling web applications to store and share data or allowing a group of people to access a shared set of files.
We will treat files as resources accessible via the HTTP protocol. Standard HTTP methods will be mapped to file operations:
GET
: Read a file or list the contents of a directory.PUT
: Create or overwrite a file.DELETE
: Delete a file or an empty directory.MKCOL
(Make Collection/Directory - not explicitly implemented in the provided snippet but a common extension): Create a new directory.
The path in the HTTP request (e.g., /myfiles/document.txt
) will be interpreted as the path to the file or directory on the server's file system.
Basic Server Structure
var http = require("http"), fs = require("fs");
var methods = Object.create(null);
http.createServer(function(request, response) {
function respond(code, body, type) {
if (!type) type = "text/plain";
response.writeHead(code, {"Content-Type": type});
if (body && body.pipe)
body.pipe(response);
else
response.end(body);
}
if (request.method in methods)
methods[request.method](urlToPath(request.url),
respond, request);
else
respond(405, "Method " + request.method
+ " not allowed.");
}).listen(8000);
Using Node.js's built-in http
module to create the server and the fs
(filesystem) module to interact with files.
// Import necessary built-in modules
const http = require('http');
const fs = require('fs');
const path = require('path'); // Useful for path manipulation
const { URL } = require('url'); // For parsing the request URL
// object to store handler functions for different HTTP methods
const methods = Object.create(null);
// Creates an empty object without prototype properties
const server = http.createServer(function (request, response) {
// Helper function to send responses back to the client
function respond(code, body, type) {
if (!type) {
type = 'text/plain';
// Default content type if not specified
}
response.writeHead(code, { 'Content-Type': type });
if (body && typeof body.pipe === 'function') {
// If 'body' is a Readable Stream (it has a .pipe() method),
// pipe its contents directly to the 'response' Writable Stream.
// This is efficient for sending large files.
body.pipe(response);
} else {
// Otherwise, assume 'body' is a string, a Buffer, or null.
// End the response, sending the body.
response.end(body);
}
}
// Safely parse the request URL to get the pathname
let requestPath;
try {
// request.url might be like "/some/path?query=true"
// We only want the "/some/path" part for file system access.
const parsedUrl = new URL(request.url,
`http://${request.headers.host}`);
requestPath = parsedUrl.pathname;
} catch (e) {
respond(400, 'Invalid URL path');
return;
}
// Convert the URL path to an absolute file system path
// This is a basic example; robust path handling would involve more security checks
// (e.g., to prevent directory traversal attacks like ../../etc/passwd)
const filePath = urlToPath(requestPath);
if (request.method in methods) {
// If we have a handler for the requested HTTP method, call it
methods[request.method](filePath, respond, request, response); // Pass response too for more flexibility
} else {
// If the method is not supported, send a 405 "Method Not Allowed" error
respond(405, `Method ${request.method} not allowed.`);
}
});
const port = 8000;
server.listen(port, () => {
console.log(`File server listening on http://localhost:${port}`);
console.log('Try commands like:');
console.log(' curl http://localhost:8000/my_directory');
console.log(' curl -X PUT -d "Hello from PUT" http://localhost:8000/newfile.txt');
console.log(' curl http://localhost:8000/newfile.txt');
console.log(' curl -X DELETE http://localhost:8000/newfile.txt');
});
// Helper function to convert URL path to a safe file system path
// WARNING: This is a simplified version. Production code needs more robust security.
function urlToPath(urlPath) {
// Decode URI components (e.g., %20 becomes a space)
let decodedPath = decodeURIComponent(urlPath);
// For security, prevent access outside the server's base directory.
// Here, we assume the server serves files from its current working directory.
const baseDirectory = process.cwd(); // Current Working Directory
let resolvedPath = path.resolve(baseDirectory, decodedPath.slice(1)); // .slice(1) to remove leading '/'
// Basic check to ensure the path doesn't try to go "above" the base directory
if (!resolvedPath.startsWith(baseDirectory)) {
// This is a naive check. More sophisticated libraries (like 'path-is-inside') are better.
// For this example, we'll throw an error that will be caught and result in a 403 or similar.
// Or, more simply, just return a path that will likely result in a 404 or access denied.
// For now, we'll let it be, but be aware of the security risk of directory traversal.
console.warn(`Potential directory traversal attempt: ${decodedPath} resolved to ${resolvedPath}`);
// To be safer, you might return a path that will deliberately fail fs.stat or throw an error.
// For simplicity in this example, we proceed, but in production, this is a critical security point.
}
return resolvedPath;
}
Explanation of the Core Structure:
methods = Object.create(null);
: This object will act as a dispatcher. We'll add properties to it where the key is an HTTP method name (e.g., "GET", "PUT") and the value is the function that handles that method. UsingObject.create(null)
creates an object with no inherited properties fromObject.prototype
, which can be slightly safer for use as a map.http.createServer(...)
: This is the standard way to create an HTTP server. The callback function is executed for every incoming request.respond(code, body, type)
function:- This is a utility function to standardize how we send responses.
- It takes an HTTP
status code
, theresponse body
, and an optionalcontent type
. - Streaming Support: If the
body
is a readable stream (identified by having apipe
method), it efficiently pipes the stream's content directly to theresponse
object (which is a writable stream). This is crucial for sending large files without loading them entirely into memory. - Otherwise, it assumes the
body
is a string ornull
and sends it usingresponse.end()
.
- URL Parsing and Path Conversion:
request.url
contains the raw URL string from the client (e.g.,/files/report.pdf?version=2
).new URL(request.url, base)
is used to parse this URL. We are primarily interested in thepathname
part.urlToPath(requestPath)
: This (currently simplified) function is responsible for converting the URL path into an actual file system path. Security is paramount here to prevent directory traversal attacks (e.g., a client requesting../../../../etc/passwd
). A real-world server needs robust path validation and sanitization.
- Method Dispatching:
if (request.method in methods)
: Checks if we have a handler defined for the HTTP method used in the request (e.g., "GET", "POST", "DELETE").- If a handler exists, it's called with the
filePath
, therespond
callback, and the originalrequest
andresponse
objects. - If no handler is defined for the method, a
405 Method Not Allowed
error is sent.
Initially, this server will return 405 Method Not Allowed
for all requests because we haven't defined any handlers in the methods
object yet.
Implementing the GET Method
The GET
method will be used to:
- Retrieve the content of a file if the path points to a file.
- List the contents of a directory if the path points to a directory.
To determine the correct Content-Type
header for files, we'll use the mime
library. MIME types (e.g., text/html
, image/jpeg
, application/pdf
) tell the browser how to interpret the file content.
Install the mime
package: Open your terminal in the directory where your server script lives and run:
npm install mime
This will download the mime
package and add it to your node_modules
directory and package.json
(if you have one).
// (Add this to your existing server code, after the 'methods' object is defined)
const mime = require('mime'); // Require the installed mime package
methods.GET = function (filePath, respond, request, response) {
fs.stat(filePath, function (error, stats) {
if (error) {
if (error.code === 'ENOENT') { // ENOENT: Error NO ENTry (file/directory does not exist)
respond(404, 'File not found');
} else {
// Other errors (e.g., permission denied)
respond(500, `Server error: ${error.toString()}`);
}
} else if (stats.isDirectory()) {
// If the path is a directory, read its contents
fs.readdir(filePath, function (error, files) {
if (error) {
respond(500, `Server error: ${error.toString()}`);
} else {
// Send a plain text list of files, one per line
respond(200, files.join('\n'));
// For a fancier directory listing, you might generate HTML here.
}
});
} else {
// If the path is a file, stream its contents
const fileStream = fs.createReadStream(filePath);
// Handle errors on the file stream (e.g., if the file is deleted after stat but before read)
fileStream.on('error', (err) => {
respond(500, `Error reading file: ${err.toString()}`);
});
// Determine the MIME type based on the file extension
const contentType = mime.getType(filePath) || 'application/octet-stream'; // Default if type unknown
respond(200, fileStream, contentType);
}
});
};
Explanation of methods.GET
:
fs.stat(filePath, callback)
: This asynchronous function retrieves information (stats) about the file or directory atfilePath
.error
: If an error occurs (e.g., file not found, permissions issue).stats
: An object containing details like file size (stats.size
), modification time (stats.mtime
), and methods to check its type.
- Error Handling (
error.code === 'ENOENT'
): Iffs.stat
returns an error and theerror.code
isENOENT
, it means the file or directory doesn't exist, so we send a404 Not Found
. Other errors result in a500 Internal Server Error
. - Directory Listing (
stats.isDirectory()
):- If
stats.isDirectory()
is true, we usefs.readdir(filePath, callback)
to asynchronously read the names of files and subdirectories within that directory. - The list of names is then joined by newline characters and sent as the response body with a
200 OK
status.
- If
- File Streaming:
- If the path points to a regular file, we create a readable stream using
fs.createReadStream(filePath)
. mime.getType(filePath)
: This function from themime
library attempts to determine the correct MIME type based on the file's extension (e.g.,.txt
->text/plain
,.html
->text/html
). If the type can't be determined,application/octet-stream
is a safe default, prompting the browser to usually download the file.- The
fileStream
and itscontentType
are passed to ourrespond
function, which will pipe the stream to the HTTP response. - An error handler is added to
fileStream
in case something goes wrong during the streaming process.
- If the path points to a regular file, we create a readable stream using
Implementing the DELETE Method
The DELETE
method will remove a file or an empty directory.
// (Add this to your existing server code)
methods.DELETE = function (filePath, respond, request, response) {
fs.stat(filePath, function (error, stats) {
if (error) {
if (error.code === 'ENOENT') {
// If the file doesn't exist, it's already "deleted".
// HTTP standard suggests DELETE should be idempotent.
// Idempotent means making the same request multiple times
// has the same effect as making it once.
respond(204); // 204 No Content (success, but no body to return)
} else {
respond(500, `Server error: ${error.toString()}`);
}
} else if (stats.isDirectory()) {
// If it's a directory, use fs.rmdir to remove it (only works if empty)
fs.rmdir(filePath, function(rmdirError) {
if (rmdirError) {
respond(500, `Error deleting directory: ${rmdirError.toString()}. Ensure it's empty.`);
} else {
respond(204); // Success, no content
}
});
} else {
// If it's a file, use fs.unlink to delete it
fs.unlink(filePath, function(unlinkError) {
if (unlinkError) {
respond(500, `Error deleting file: ${unlinkError.toString()}`);
} else {
respond(204); // Success, no content
}
});
}
});
};
// The original note had a 'respondErrorOrNothing' helper.
// While good for DRY, for clarity in these expanded notes,
// the logic is inlined in the fs.rmdir and fs.unlink callbacks.
// You could define it like this if preferred:
/*
function respondErrorOrNothing(respondCallback) {
return function(error) {
if (error) {
respondCallback(500, error.toString());
} else {
respondCallback(204); // No Content
}
};
}
// And then use it:
// fs.rmdir(filePath, respondErrorOrNothing(respond));
// fs.unlink(filePath, respondErrorOrNothing(respond));
*/
Explanation of methods.DELETE
:
fs.stat
: First, we check if the target path exists and what it is.- Non-Existent File (
error.code === 'ENOENT'
): If the file doesn't exist, theDELETE
operation can be considered successful (idempotency). We respond with204 No Content
, which indicates success without a response body. - Deleting a Directory (
stats.isDirectory()
):- If the path is a directory,
fs.rmdir(filePath, callback)
is used. This function only successfully removes empty directories. If the directory is not empty, it will result in an error.
- If the path is a directory,
- Deleting a File:
- If the path is a file,
fs.unlink(filePath, callback)
is used to delete it.
- If the path is a file,
- Callbacks and
204 No Content
: For successful deletions, we respond with204 No Content
.
Implementing the PUT Method
The PUT
method will be used to create a new file or overwrite an existing file with the content provided in the request body.
// (Add this to your existing server code)
methods.PUT = function (filePath, respond, request, response) {
// Create a Writable Stream to the target file path.
// This will create the file if it doesn't exist, or truncate it if it does.
const outputStream = fs.createWriteStream(filePath);
// Handle errors during file writing (e.g., disk full, permissions)
outputStream.on('error', function (error) {
console.error('Error writing to file:', error);
respond(500, `Server error writing file: ${error.toString()}`);
});
// When the 'request' stream (Readable) has been fully piped to 'outputStream' (Writable)
// and 'outputStream' has finished writing all data to the file system,
// the 'finish' event is emitted on the 'outputStream'.
outputStream.on('finish', function () {
respond(204); // Success, no content to return (file created/updated)
// Some might use 201 Created if it's a new resource.
});
// The 'request' object is a Readable Stream representing the incoming request body.
// Pipe the request body directly to the file's Writable Stream.
// This efficiently streams data from the client to the file without buffering
// the entire content in memory.
request.pipe(outputStream);
// It's also good practice to handle errors on the request stream itself,
// in case the client disconnects prematurely or sends malformed data.
request.on('error', (err) => {
console.error('Error reading request stream for PUT:', err);
// Try to clean up the partially written file if an error occurs on the request stream
outputStream.end(() => { // Ensure outputStream is closed
fs.unlink(filePath, (unlinkErr) => { // Attempt to delete partial file
if (unlinkErr) console.error('Error deleting partial file on PUT error:', unlinkErr);
});
});
// It's tricky to send a response here as the outputStream might have already sent headers.
// Best effort is to ensure the connection is closed if not already handled by Node's internals.
});
};
Explanation of methods.PUT
:
fs.createWriteStream(filePath)
: Creates a writable stream to the specifiedfilePath
.- If the file doesn't exist, it will be created.
- If the file exists, its content will be overwritten (truncated).
- Event Handling on
outputStream
:'error'
: Catches errors that occur while writing to the file (e.g., disk full, no permissions).'finish'
: This event is emitted when all data has been flushed to the underlying system (i.e., written to the file) and the stream has been closed. We respond with204 No Content
to indicate success. (Some APIs use201 Created
if the resource was newly created, and200 OK
or204 No Content
if it was modified.204
is simple and common forPUT
).
request.pipe(outputStream)
: This is the core of thePUT
handler.- The
request
object (for incoming HTTP requests with a body like PUT or POST) is a readable stream. - We
pipe
the data from therequest
stream directly into theoutputStream
(the file). This is highly efficient for transferring file content, as it avoids loading the entire request body into memory.
- The
- Error Handling on
request
stream: It's important to also handle errors on therequest
stream. If the client aborts the upload, thiserror
event will fire. Attempting to clean up partially written files is good practice.
Testing with curl
The curl
command-line utility is excellent for testing HTTP servers.
curl http://localhost:8000/file.txt
- Makes a
GET
request. Initially, this should respond withFile not found
(or a 404 error).
- Makes a
curl -X PUT -d "hello content" http://localhost:8000/file.txt
-X PUT
: Specifies thePUT
HTTP method.-d "hello content"
: Sends "hello content" as the request body.- This should create
file.txt
(or overwrite it) with "hello content". The server should respond with204 No Content
.
curl http://localhost:8000/file.txt
- Another
GET
request. This time, it should respond with "hello content".
- Another
curl -X DELETE http://localhost:8000/file.txt
-X DELETE
: Specifies theDELETE
HTTP method.- This should delete
file.txt
. The server should respond with204 No Content
.
curl http://localhost:8000/file.txt
- A final
GET
request. This should again respond withFile not found
.
- A final
To test directory listing:
- Manually create a directory in the same folder as your server script, e.g.,
mkdir my_test_dir
. - Put a file in it, e.g.,
echo "test file" > my_test_dir/test.txt
. curl http://localhost:8000/my_test_dir
- This should list
test.txt
.
- This should list
Error Handling Considerations (Callback Hell and Promises)
The provided code uses nested callbacks extensively, which is common in traditional Node.js I/O operations. This can lead to what's often called "callback hell" or the "pyramid of doom," making code harder to read, reason about, and maintain.
Challenges with Callback-Based Error Handling:
- Error Propagation: Errors are not automatically propagated up the call stack as they are with synchronous exceptions. Each callback needs to explicitly check for an
error
argument and handle or pass it along. - Repetitive Code: You often end up writing similar error-checking logic in multiple callbacks.
- Centralized Error Handling: It's difficult to have a single, centralized place to catch all errors for a given request-response cycle using only
try/catch
at the top level, because asynchronous operations happen outside the initialtry
block's execution scope.
Using Promises for Better Asynchronous Code:
Promises provide a more structured way to manage asynchronous operations and their outcomes (success or failure).
- Chaining: Promises can be chained (
.then().then().catch()
), leading to flatter, more readable code. - Error Handling: A single
.catch()
at the end of a promise chain can handle errors from any preceding asynchronous operation in the chain. - Composition: Promises make it easier to compose multiple asynchronous operations.
Node.js and Promises:
- Many modern Node.js libraries and newer versions of built-in modules (like
fs.promises
) directly return Promises. - For older callback-based APIs, you can "promisify" them. The
util.promisify
utility in Node.js can convert many standard callback-style functions into promise-returning functions. - Third-party promise libraries (though
util.promisify
and native Promises are often sufficient now) likebluebird
historically offered advanced features, including functions likedenodeify
(mentioned in your notes, similar in purpose toutil.promisify
).
Example of Promisifying fs.stat
(Conceptual):
const util = require('util');
const fs = require('fs');
const statAsync = util.promisify(fs.stat);
// ... inside your GET handler ...
// methods.GET = async function (filePath, respond, request, response) { // Note: async function
// try {
// const stats = await statAsync(filePath);
// if (stats.isDirectory()) {
// const files = await util.promisify(fs.readdir)(filePath);
// respond(200, files.join('\n'));
// } else {
// const fileStream = fs.createReadStream(filePath);
// // ... (stream handling as before) ...
// respond(200, fileStream, mime.getType(filePath) || 'application/octet-stream');
// }
// } catch (error) {
// if (error.code === 'ENOENT') {
// respond(404, 'File not found');
// } else {
// respond(500, `Server error: ${error.toString()}`);
// }
// }
// };
Using async/await
with promises drastically improves the readability and structure of asynchronous code, making error handling more akin to synchronous try/catch
blocks.
Refactoring the entire file server to use Promises and async/await
would be a good exercise to see the benefits in action.