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.wasmfile. - [ ] Optionally, precompressed
.wasm.brand.wasm.gzfiles 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.
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.
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-corpblocks cross-origin resources that do not opt in. Drop the isolation headers if you do not need threads, or usecredentiallessfor COEP. - The
.brfile 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.watchfires 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.
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.
Related
- Serving Wasm with the correct MIME type locally — the first header this server sets.
- Configuring COOP/COEP headers for SharedArrayBuffer — the isolation headers in depth.
- Compressing Wasm with Brotli for delivery — producing the
.brfiles. - Serving Wasm files with the right headers — the production equivalent.
← Back to Local Development Server Configurations