πŸš€AdvancedNode.js Practice Projects

Project: File Upload Server

Build an HTTP file upload server using Node.js built-in http module, multipart/form-data parsing, and fs streams

Overview

This project is the culmination of the Node.js Fundamentals phase. You will build a complete HTTP file upload server using only Node.js built-in modules β€” no Express, no Multer, no npm packages of any kind. This constraint forces you to understand what web frameworks and middleware do under the hood: read the raw HTTP body, detect content boundaries, parse multipart/form-data, and save the uploaded bytes to disk using streams. The server handles the two most common HTTP interactions for file uploads: a GET request that serves an HTML upload form to the browser, and a POST request that receives the multipart body, extracts the file data, and saves it to an uploads/ directory. You will also implement basic validation (file size limits, allowed extensions) and return JSON responses so the form can display results without a full page reload. Parsing multipart/form-data manually is illuminating. Every file upload request contains a Content-Type header like `multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW`. The body uses this boundary string as a delimiter between parts. Each part has its own mini-headers (Content-Disposition, Content-Type) followed by the raw file bytes. You will use the boundary to split the body, extract the filename from Content-Disposition, and write only the file bytes to disk β€” skipping the boundary markers and part headers. After completing this project you will understand exactly what happens when a browser submits a file β€” the HTTP wire format, the multipart encoding, and the server-side parsing. This knowledge directly applies to debugging upload issues in Express and understanding what Multer is abstracting away for you.

Key Points

  • β€’http.createServer receives an IncomingMessage (request) and ServerResponse (response) for every connection
  • β€’The request body arrives as a Readable stream β€” collect it with a "data" event loop or use stream/promises
  • β€’multipart/form-data bodies contain multiple parts separated by a boundary string found in the Content-Type header
  • β€’Each multipart part has a Content-Disposition header containing the field name and filename
  • β€’File bytes begin after the blank line following the part headers and end before the next boundary
  • β€’Always validate file size, extension, and MIME type before writing to disk to prevent path traversal and oversized uploads

Examples

The finished server β€” test it with curl or a browser form

# Start the server:
node server.js
# Listening on http://localhost:3000

# Upload a file with curl:
curl -X POST http://localhost:3000/upload \
  -F "file=@./photo.jpg"
# {"success":true,"filename":"photo.jpg","size":204800,"path":"/uploads/photo.jpg"}

# Or open http://localhost:3000 in a browser β€” it serves the upload form

# List uploaded files:
curl http://localhost:3000/files
# {"files":["photo.jpg","report.pdf"]}

Creating the HTTP server and routing requests

const http = require('http');
const fs   = require('fs');
const path = require('path');

const UPLOAD_DIR = path.join(__dirname, 'uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true }); // ensure uploads/ exists

const server = http.createServer((req, res) => {
  const url    = req.url;
  const method = req.method;

  if (method === 'GET'  && url === '/')         return serveForm(res);
  if (method === 'GET'  && url === '/files')    return listFiles(res);
  if (method === 'POST' && url === '/upload')   return handleUpload(req, res);
  res.writeHead(404); res.end('Not found');
});

server.listen(3000, () => console.log('Listening on http://localhost:3000'));

Collecting the multipart body and extracting the boundary

async function handleUpload(req, res) {
  const contentType = req.headers['content-type'] || '';

  // Extract boundary: multipart/form-data; boundary=----WebKit...
  const boundaryMatch = contentType.match(/boundary=(.+)/);
  if (!boundaryMatch) return sendJson(res, 400, { error: 'No boundary found' });
  const boundary = boundaryMatch[1];

  // Collect the full body (Buffer, not string β€” files are binary)
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const body = Buffer.concat(chunks);

  // Parse out the file part
  const file = parseMultipart(body, boundary);
  if (!file) return sendJson(res, 400, { error: 'No file in request' });

  // Validate extension
  const ext = path.extname(file.filename).toLowerCase();
  if (!['.jpg', '.jpeg', '.png', '.pdf', '.txt'].includes(ext))
    return sendJson(res, 400, { error: 'File type not allowed' });

  const savePath = path.join(UPLOAD_DIR, file.filename);
  await fs.promises.writeFile(savePath, file.data);
  sendJson(res, 200, { success: true, filename: file.filename, size: file.data.length });
}

Parsing the multipart body β€” splitting on boundary and extracting file bytes

function parseMultipart(body, boundary) {
  const delimiter = Buffer.from('--' + boundary);
  const parts     = splitBuffer(body, delimiter);

  for (const part of parts) {
    // Each part starts with headers, then \r\n\r\n, then the data
    const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'));
    if (headerEnd === -1) continue;

    const headerStr = part.slice(0, headerEnd).toString();
    const data      = part.slice(headerEnd + 4); // skip \r\n\r\n

    // Content-Disposition: form-data; name="file"; filename="photo.jpg"
    const nameMatch = headerStr.match(/filename="([^"]+)"/);
    if (!nameMatch) continue; // not a file part

    // Strip trailing \r\n before the next boundary
    const fileData = data.slice(0, data.length - 2);
    return { filename: path.basename(nameMatch[1]), data: fileData };
  }
  return null;
}

function splitBuffer(buf, delimiter) {
  const parts  = [];
  let   offset = 0;
  let   idx;
  while ((idx = buf.indexOf(delimiter, offset)) !== -1) {
    parts.push(buf.slice(offset, idx));
    offset = idx + delimiter.length;
  }
  parts.push(buf.slice(offset));
  return parts.filter(p => p.length > 2);
}

Common Mistakes

  • Reading the request body with req.on("data") using toString() β€” uploaded files are binary data and toString() corrupts non-UTF-8 bytes; always collect Buffer chunks and concat them
  • Using string split() on the multipart body β€” the boundary must be searched as a Buffer to avoid corrupting binary file content
  • Skipping file extension validation β€” a user could upload a .exe, .php, or a 1 GB file; always whitelist extensions and check Content-Length if available
  • Not handling CORS headers β€” browsers block cross-origin form submissions by default; add Access-Control-Allow-Origin if the form is served from a different origin

Note

For production file uploads use the Multer npm package with Express β€” it handles multipart parsing, streaming to disk, file size limits, and extension filtering robustly. This project shows what Multer does internally; in real apps you should not reinvent it.

Exercises

Write Node.js code in your local terminal. Use the editor below to follow along, then mark each exercise as done once it works.

Loading editor…

Completed: 0 / 6 exercises

Knowledge Check

Question 1 of 4

Why must you collect request body chunks as Buffers rather than strings when handling file uploads?