Building a Local Server for Static Site Development from Scratch

Introduction

A local development server is an essential tool for web development that allows you to:

🤔 Why build from scratch?

While these features come built-in with modern tools, building them yourself provides valuable insights into how core systems operate.

🔗 Previous Material:

If you missed the introductory article, check out how I built a static site generator from scratch using NodeJS!

Core System Features:

Monitoring File Changes

Initially used NodeJS's native fs.watch, but switched to chokidar for better cross-platform support. I was just tired of duplicated file change notifications.

🤓 Interesting? Maybe you're interested in a system that builds an index and tracks real file content changes? So the generator only runs when the file content has actually changed? If so, please let me know!

Site Regeneration

I launch the regeneration process in a separate thread. The development build includes a special script injection for live reloading.

A debounce function prevents multiple regeneration triggers:

export const debounce = (fn, ms) => {
  let timeoutId = null;
  return (...args) => {
    if (timeoutId != null) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
    return new Promise((resolve) => {
      timeoutId = setTimeout(() => {
        timeoutId = null;
        const result = fn(...args);
        resolve(result);
      }, ms);
    });
  };
};

If a previous build is running, abort it before starting a new one:

const proc = runProcess("node", ["--import", "tsx", "scripts/dev-rebuild.ts"]);
// After this, I can:
// 1. Wait for process completion
await proc.promise;
// 2. Abort and terminate the process (proc.promise will reject)
await proc.abort();

Local HTTP Server

Create a custom HTTP server (e.g., at http://localhost:3000/) to serve files from the build directory.

Path Handling Logic

Server routing mimics production environment rules:

Development Mode & Page Reloading

Implement EventStream connections for live reloading. Inject this script into every HTML page:

// Connect to dev server at http://localhost:3000/dev-server
new EventSource("/dev-server").addEventListener("message", (ev) => {
  if (ev?.data === "reload") {
    location.reload();
  }
});

Pro Tip: Inject the script dynamically during file serving rather than at build time. This keeps static pages unaware of the reload mechanism.

Next, handle event stream on local server, store all active connections just in memory and broadcast reload event:

// Store all active connections
const clients = new Set();

http.createServer(async (req, res) => {
  if (req.url === "/dev-server") {
    res.writeHead(200, {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    });
    res.write("data: init\n\n");

    // Add new connection
    clients.add(res);

    req.on("close", () => {
      clients.delete(res);
      res.end();
    });
    return;
  }
  // ...
});

export const reloadPages = () => {
  for (const client of clients) {
    client.write("data: reload\n\n");
  }
};

Conclusion

My system now can:


❤️ Your feedback matters!