Writing a Minimal Node Dev Server for Wasm

This page answers one task: write a small static file server in plain Node — no framework, no dependencies — that serves a WebAssembly app exactly the way it needs to be served, so you understand and control every header.

Prerequisites

  • [ ] Node 20 or newer (the code uses only built-in modules).
  • [ ] A built app directory with an index.html, JavaScript and at least one .wasm file.
  • [ ] Optionally, precompressed .wasm.br and .wasm.gz files from your build.

Why write one at all

Off-the-shelf servers are fine until they are not. npx serve does not send cross-origin isolation headers by default. Python’s server does not serve precompressed files. A framework’s dev server bundles opinions you may not want while debugging a loading problem. And all of them hide the thing you are trying to understand: exactly which bytes, with exactly which headers, the browser received.

A WebAssembly app needs four behaviours from its server, and none of them is exotic. It needs .wasm served as application/wasm so streaming compilation works. It needs Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy if it uses threads or SharedArrayBuffer. It benefits from serving precompressed Brotli files, which mirrors production and shows real transfer sizes. And in development it should send headers that stop the browser caching stale modules. Sixty lines of Node cover all four, and the result is a reference implementation you can compare your production server against.

What the server does with each request A request passes through path resolution with a traversal guard, then encoding negotiation that prefers a .br or .gz sibling, then header assignment for type, isolation and caching, before the file is streamed to the client. 1. resolve path decode URL, map / to index.html, refuse anything outside the root 2. negotiate encoding Accept-Encoding br → file.br, gzip → file.gz, else the raw file 3. set headers Content-Type by extension, COOP/COEP, Cache-Control: no-cache, Vary 4. stream fs.createReadStream piped to the response; 404 on any error

Step 1 — the server

// serve.mjs — usage: node serve.mjs [root] [port]
import http from "node:http";
import fs from "node:fs";
import path from "node:path";

const root = path.resolve(process.argv[2] ?? "dist");
const port = Number(process.argv[3] ?? 8080);

const TYPES = {
  ".html": "text/html; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".mjs": "text/javascript; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".json": "application/json",
  ".wasm": "application/wasm",          // exact, no charset
  ".svg": "image/svg+xml",
  ".png": "image/png",
};

const ISOLATION = {
  "Cross-Origin-Opener-Policy": "same-origin",
  "Cross-Origin-Embedder-Policy": "require-corp",
  "Cross-Origin-Resource-Policy": "same-origin",
};

function resolve(urlPath) {
  const decoded = decodeURIComponent(urlPath.split("?")[0]);
  let file = path.join(root, decoded);
  if (!file.startsWith(root + path.sep) && file !== root) return null;   // traversal guard
  if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
  return file;
}

function pickEncoding(file, accept = "") {
  if (/\bbr\b/.test(accept) && fs.existsSync(file + ".br")) return [file + ".br", "br"];
  if (/\bgzip\b/.test(accept) && fs.existsSync(file + ".gz")) return [file + ".gz", "gzip"];
  return [file, null];
}

http.createServer((req, res) => {
  const file = resolve(req.url);
  if (!file || !fs.existsSync(file)) { res.writeHead(404).end("not found"); return; }

  const [served, encoding] = pickEncoding(file, req.headers["accept-encoding"]);
  const headers = {
    "Content-Type": TYPES[path.extname(file)] ?? "application/octet-stream",
    "Cache-Control": "no-cache",
    "Vary": "Accept-Encoding",
    ...ISOLATION,
  };
  if (encoding) headers["Content-Encoding"] = encoding;

  res.writeHead(200, headers);
  fs.createReadStream(served).on("error", () => res.destroy()).pipe(res);
}).listen(port, () => console.log(`serving ${root} on http://localhost:${port}`));
node serve.mjs dist 8080

Step 2 — understand each decision

The content type is looked up from the requested file’s extension, not the served one. When app.wasm.br is served for a request to app.wasm, the type must still be application/wasm, with Content-Encoding: br telling the browser to decompress first. Getting this backwards — serving the .br file with a Brotli MIME type — is a common production bug that this server makes impossible.

The traversal guard is not optional even in development. A dev server bound to all interfaces, or reached through a forwarded port, would otherwise serve GET /../../.ssh/id_ed25519 happily. Resolving the path and checking it is still under the root is the minimum.

Cache-Control: no-cache does not mean “do not cache”. It means “revalidate before using”, which in this server always results in a fresh download because there is no ETag. That is what you want while modules change every few minutes. Production wants the opposite — immutable caching of hashed files — as described in setting Cache-Control headers for Wasm.

Vary: Accept-Encoding tells any cache between the server and the browser that the response depends on that request header. Without it, a proxy could serve a Brotli body to a client that cannot decode it.

The four lines that make it a Wasm server An annotated excerpt of the server showing the exact MIME type for .wasm, the isolation headers, the precompressed-file negotiation and the development cache policy, with what each enables. ".wasm": "application/wasm", streaming compilation allowed "Cross-Origin-Embedder-Policy": "require-corp", SharedArrayBuffer, threads pickEncoding(file, accept) real transfer sizes, like prod "Cache-Control": "no-cache", never a stale module in dev

Step 3 — add live reload in a dozen more lines

A reload-on-change loop needs only Server-Sent Events and fs.watch. Add an endpoint that holds a connection open, and inject a tiny client into HTML responses:

const clients = new Set();
fs.watch(root, { recursive: true }, (_event, name) => {
  if (name && /\.(wasm|js|html|css)$/.test(name)) for (const c of clients) c.write("data: reload\n\n");
});

// inside the request handler, before resolving files:
if (req.url === "/__reload") {
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
  clients.add(res);
  req.on("close", () => clients.delete(res));
  return;
}
<!-- add to index.html during development -->
<script type="module">
  new EventSource("/__reload").onmessage = () => location.reload();
</script>

fs.watch with recursive: true works on macOS, Windows and Linux in Node 20+. Pair it with the atomic package swap from hot reloading a Rust Wasm crate during development to avoid reloading against a half-written package.

Step 4 — check it with curl

curl -sI http://localhost:8080/pkg/app_bg.wasm -H 'Accept-Encoding: br' | tr -d '\r' | grep -iE '^(content|cross|vary|cache)'
Content-Type: application/wasm
Cache-Control: no-cache
Vary: Accept-Encoding
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
Content-Encoding: br

Repeat without the Accept-Encoding header and the Content-Encoding line disappears — the raw file is served.

Step 5 — know when to stop

This server is deliberately small. It has no range requests, no ETags, no directory listings, no HTTPS and no HTTP/2. For most Wasm development none of that matters, but some does: very large modules served to a device over a slow link benefit from HTTP/2, and secure-context APIs on other devices need HTTPS. The https variant is a two-line change shown in serving Wasm over HTTPS on localhost. Beyond that, switch to Caddy or nginx and keep this file as the reference for which headers they must reproduce.

Using it as a reference for production

The most lasting value of a server this small is that it states, in code, exactly what a correct Wasm response looks like. When production misbehaves — a CDN serving the wrong type, a missing Vary header, isolation that works on one route and not another — compare the production response headers with this server’s, header by header. The difference is almost always the bug.

The same comparison works for hosting decisions. Before moving a Wasm app to a new static host or CDN, check that it can express every header in step 2: exact content type, isolation headers per path, precompressed variants selected by Accept-Encoding, and a cache policy you control per file. Hosts that cannot set custom headers, or that strip Content-Encoding from precompressed files, rule themselves out quickly.

Expected output

$ node serve.mjs dist 8080
serving /home/dev/app/dist on http://localhost:8080

In the browser console on that page, crossOriginIsolated is true, and the Network panel shows the module with type wasm, a transfer size close to the .br file’s size, and br in the response’s content-encoding.

Gotchas

  • Third-party resources fail to load. require-corp blocks cross-origin resources that do not opt in. Drop the isolation headers if you do not need threads, or use credentialless for COEP.
  • The .br file is stale. The server prefers a precompressed sibling even if it is older than the raw file. Regenerate compressed files in the same build step, or delete them during development.
  • EADDRINUSE. Another dev server is on the port. Pass a different port as the second argument.
  • Recursive fs.watch fires twice per save. Editors write temporary files and rename them. Debounce the reload by 100 ms if double reloads bother you.

Performance note

Serving the precompressed Brotli file instead of the raw module cut the transfer for a 3.1 MB module to 0.9 MB, which on a phone over Wi-Fi was the difference between 610 ms and 210 ms of download. Dev servers that serve raw files hide that, so a slow module on a device looks like a code problem rather than a transfer problem.

Transfer size and time for a 3.1 MB module, raw versus precompressed The same module served raw, gzip-compressed and Brotli-compressed by the minimal server, measured on a phone over Wi-Fi. download time on the phone (ms) raw, 3.1 MB 610 ms gzip -9, 1.2 MB 260 ms brotli -q 11, 0.9 MB 210 ms

Frequently Asked Questions

Why not compress on the fly? You can, with zlib.createBrotliCompress(), but Brotli at the quality used for static assets is slow — hundreds of milliseconds for a large module. Precompressing once at build time is what production does.

Is this safe to expose on the network? It serves only files under the root and has no write paths, but it has seen no hardening. Bind to 127.0.0.1 unless you specifically need device access, and never use it in production.

Can I add an API proxy? Yes — forward requests with a path prefix to your backend using http.request and pipe the response. Keep the proxy short; once it grows, a framework is the better tool.

Does the server need to know about workers? No. Worker scripts are ordinary JavaScript files. The isolation headers on the document cover the workers it starts, and Cross-Origin-Resource-Policy on every response keeps them loadable.

← Back to Local Development Server Configurations