Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Serving static files

Static files are the ones sent to the browser as they are, without processing: HTML pages, styles, scripts, images. Frameworks serve them with one line of configuration, but with node:http the server itself must find the file on disk, read it and set the right Content-Type.

The public folder

Create a public folder in the project root with two files in it:

blog/
├── public/
│   ├── index.html
│   └── style.css
├── src/
│   └── server.js
└── package.json
<!-- public/index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Notes by Anna</title>
    <link rel="stylesheet" href="/style.css">
  </head>
  <body>
    <h1>Notes by Anna</h1>
  </body>
</html>
/* public/style.css */
h1 {
  color: darkslateblue;
}

Type by extension

The browser knows what it received only from the Content-Type header. The mapping from extensions to types is an object:

// src/static.js
export const MIME_TYPES = {
  '.html': 'text/html; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.json': 'application/json; charset=utf-8',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.svg': 'image/svg+xml',
};

For unknown extensions the type application/octet-stream — "just bytes" — is used.

The serving function

Add to src/static.js a function that looks for a file in public and sends it:

// src/static.js
import path from 'node:path';
import { readFile } from 'node:fs/promises';
 
const PUBLIC_DIR = path.join(import.meta.dirname, '..', 'public');
 
export async function serveStatic(pathname, res) {
  const name = pathname === '/' ? 'index.html' : pathname;
  const file = path.join(PUBLIC_DIR, name);
 
  if (!file.startsWith(PUBLIC_DIR + path.sep)) return false;
 
  try {
    const content = await readFile(file);
    const type = MIME_TYPES[path.extname(file)] ?? 'application/octet-stream';
    res.writeHead(200, { 'Content-Type': type });
    res.end(content);
    return true;
  } catch {
    return false;
  }
}

The function returns true if the file was found and sent, and false if it does not exist. No encoding is passed to readFile(): images are bytes, not text.

Wiring it into the server

In src/server.js the server first tries to serve a file and only then the ordinary routes:

// src/server.js
import http from 'node:http';
import { serveStatic } from './static.js';
 
const server = http.createServer(async (req, res) => {
  const { pathname } = new URL(req.url, 'http://localhost');
 
  if (req.method === 'GET' && (await serveStatic(pathname, res))) return;
 
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
  res.end('Page not found');
});
 
server.listen(3000);

http://localhost:3000 shows the purple heading "Notes by Anna": the browser loaded the page and, with a separate request, /style.css.

Common mistake

If the path is built from the address without a check, a visitor can request /../.env and read your passwords — this is called path traversal. That is why serveStatic() has this line:

// src/static.js
if (!file.startsWith(PUBLIC_DIR + path.sep)) return false;

After path.join() every .. is already resolved, and if the final path ends up outside public, the file is not served. Never remove this check.

Thanks for reading!

Donate to the site — we work hard for you.