Skip to content
academia.sh

Lesson 13 / 19

Development Server

The compile's second mode; transforming and serving modules on request, reuse via a validator, modeling hot module replacement as invalidation propagation, and the correctness conditions of the accept boundary.

Contents

The previous five lessons built a single mode: compiling for release. In this mode the graph is scanned in full, chunks are merged, dead code is dropped, assets are processed, and names are bound to content. Every one of these steps pays off — but only in release.

For someone changing a formatting line in the North Slope Measurement Station interface, none of these steps do any work. Re-scanning the entire graph, recomputing hashes, and reloading the page from scratch costs two things: seconds, and the screen’s state. The panel being open, the filter being set, the scroll position — a full load erases all of it.

This lesson builds the compile’s second mode. Its question is: when a file changes, what is the least that can be done?

The Difference Between the Modes

Development mode drops most of release mode’s steps.

Bundling drops. The browser can load standard modules on its own; traversing the graph is left to the browser’s module loader. The server’s job is reduced to handing over a single module when it is requested.

Tree shaking, minification, and content hashing drop. All three shrink output size; size is not decisive on a local connection, and all three require seeing the whole graph.

What has to stay is three things: specifier resolution — the browser cannot resolve bare specifiers — translating syntax the target does not understand, and source map generation. The Source Maps lesson in the Modules, Tooling and the Ecosystem course showed why this third one is indispensable: transformed code cannot be debugged.

This dropping makes the cost independent of graph size. In release mode, a change concerns the entire graph; in development mode, only the file that changed.

Serving on Request

The server below performs a single transform and serves a single file.

This lesson’s server serves the source/ tree set up in the previous lessons; those lessons’ setup blocks must run in this directory first.

// dev-server.mjs — serves modules on request, transformed, with a validator.
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { readFileSync, existsSync } from "node:fs";

// A single transform: relative specifiers are rewritten to absolute paths on the server.
// No bundling; each module stays its own file.
function transform(urlPath, source) {
  return "// [dev] " + urlPath + "\n" + source
    .replace(/from\s+"\.\/([^"]+)"/g, 'from "/source/$1"')
    .replace(/^import\s+"\.\/([^"]+)"/gm, 'import "/source/$1"')
    .replace(/import\(\s*"\.\/([^"]+)"\s*\)/g, 'import("/source/$1")');
}

createServer((request, response) => {
  response.sendDate = false;                     // keeps the output deterministic
  const urlPath = request.url.split("?")[0];
  const filePath = urlPath.replace(/^\//, "");
  if (!urlPath.startsWith("/source/") || !existsSync(filePath)) {
    response.writeHead(404).end();
    return;
  }
  const body = transform(urlPath, readFileSync(filePath, "utf8"));
  const tag = '"' + createHash("sha256").update(body).digest("hex").slice(0, 12) + '"';
  if (request.headers["if-none-match"] === tag) {
    response.writeHead(304, { ETag: tag }).end();
    return;
  }
  response.writeHead(200, {
    "Content-Type": "text/javascript; charset=utf-8",
    "Content-Length": Buffer.byteLength(body),
    "Cache-Control": "no-cache",
    ETag: tag,
  }).end(body);
}).listen(8172, "127.0.0.1", () => console.log("listening: 127.0.0.1:8172"));

The party that traverses the graph is the browser. The client below is a small stand-in for the browser’s module loading loop: it fetches a module, reads the static specifiers inside it, and requests them. It does not track dynamic import — because the browser does not either, it waits until the call executes.

// client.mjs — a small stand-in for the browser's module loading loop.
// Only static specifiers are tracked; import() is not requested until it executes.
const ROOT = "http://127.0.0.1:8172";
const STATIC = [/from\s*"([^"]+)"/g, /^import\s*"([^"]+)"/gm];

async function load(roots) {
  const fetched = new Map(), queue = [...roots];
  while (queue.length > 0) {
    const urlPath = queue.shift();
    if (fetched.has(urlPath)) continue;
    const body = await (await fetch(ROOT + urlPath)).text();
    fetched.set(urlPath, body.length);
    for (const pattern of STATIC)
      for (const [, b] of body.matchAll(pattern)) if (b.startsWith("/source/")) queue.push(b);
  }
  return fetched;
}

const short = (m) => [...m].map(([p]) => p.replace("/source/", "")).sort().join(", ");
const first = await load(["/source/entry-browser.js"]);
console.log("received at first load : " + first.size + " module  " + short(first));
const after = await load([...first.keys(), "/source/panel.js"]);
const extra = new Map([...after].filter(([p]) => !first.has(p)));
console.log("extra once panel opens : " + extra.size + " module  " + short(extra));
console.log("alert.js not yet requested: " + !after.has("/source/alert.js"));

The long-lived server is started with its own script. The port chosen, 8172, is arbitrary and must be free.

#!/usr/bin/env bash
# Starts the server, requests a single module, tests the validator, runs the module loop.
cp source/template.js template.orig
node dev-server.mjs > /dev/null &
server=$!
sleep 1

echo "--- how a single module is served ---"
curl -sS http://127.0.0.1:8172/source/template.js

TAG=$(curl -sS -o /dev/null -D - http://127.0.0.1:8172/source/template.js \
  | awk 'tolower($1) == "etag:" { print $2 }' | tr -d '\r')
echo "--- validator $TAG ---"
curl -sS -o /dev/null -w "unchanged, second request : %{http_code}\n" \
  -H "If-None-Match: $TAG" http://127.0.0.1:8172/source/template.js
printf '\nexport const footnote = () => "measurements UTC";\n' >> source/template.js
curl -sS -o /dev/null -w "after the file changed     : %{http_code}\n" \
  -H "If-None-Match: $TAG" http://127.0.0.1:8172/source/template.js

echo "--- loading on request ---"
node client.mjs

kill "$server"
mv template.orig source/template.js
--- how a single module is served ---
// [dev] /source/template.js
import { measurementLine } from "/source/format.js";

export const list = (measurements) =>
  "<ul>" + measurements.map((m) => "<li>" + measurementLine(m) + "</li>").join("") + "</ul>";
export const heading = (station) => "<h1>" + station + "</h1>";
--- validator "6b87f32456fc" ---
unchanged, second request : 304
after the file changed     : 200
--- loading on request ---
received at first load : 9 module  date.js, entry-browser.js, forecast.js, format.js, registry.js, scale-registry.js, template.js, toolkit.js, units.js
extra once panel opens : 3 module  chart.js, csv-export.js, panel.js
alert.js not yet requested: true

The validator’s value is produced from the served module’s bytes: the same byte sequence always gives the same value, a one-character change gives a different one. The value here depends on this directory’s content at that moment — because earlier lessons’ setup blocks rewrite the same file, a different validator can be seen depending on which blocks were run. What matters is not the value itself but that it stays the same for an unchanged module.

The output’s last three lines show development mode’s gain. At first load, nine of thirteen modules are requested; three more come once the panel opens; the threshold-alert module is never requested, because the dynamic call that requests it has not yet executed. At no stage is the entire graph scanned.

The validator lines give the second gain. A module whose content has not changed is dispatched with a bodyless response on the second request; when the page reloads, only the changed files download again.

Invalidation Propagation

Updating without reloading the page requires more than this. When a module’s new version is fetched, the values held by the modules that imported it are stale: imported bindings must be reread, and those modules must be re-evaluated too. The change propagates through the graph against the direction of the edges — from the requested toward the requester.

For the propagation not to go on forever, a stop is needed. The accept boundary is a module that declares it can absorb its own update: it is replaced with its new version, and its importers are not notified. If no boundary is found and the propagation reaches the entry point, the only thing left to do is a full load.

// hot.mjs — invalidation propagation in the module graph and the accept boundary.
import { readFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";

const ENTRY = "source/entry-browser.js";
const LINK = [/from\s+"(\.[^"]+)"/g, /^import\s+"(\.[^"]+)"/gm, /import\(\s*"(\.[^"]+)"\s*\)/g];

// Accept boundary: a module that absorbs its own update without propagating to its importers.
const ACCEPT = new Set(["source/template.js", "source/panel.js", "source/chart.js"]);
// Modules holding module-level state: state resets if they are re-evaluated.
const STATE = new Set(["source/registry.js"]);

const graph = new Map(), queue = [ENTRY];
while (queue.length > 0) {
  const filePath = queue.shift();
  if (graph.has(filePath)) continue;
  const source = readFileSync(filePath, "utf8");
  const neighbors = LINK.flatMap((k) => [...source.matchAll(k)]
    .map(([, b]) => relative(".", join(dirname(filePath), b))));
  graph.set(filePath, neighbors);
  queue.push(...neighbors);
}

const importers = new Map([...graph.keys()].map((p) => [p, []]));
for (const [filePath, neighbors] of graph) for (const n of neighbors) importers.get(n).push(filePath);

function propagate(changed) {
  const invalidated = [], visited = new Set(), queue = [changed];
  let fullReload = false;
  while (queue.length > 0) {
    const filePath = queue.shift();
    if (visited.has(filePath)) continue;
    visited.add(filePath);
    invalidated.push(filePath);
    if (ACCEPT.has(filePath)) continue;             // boundary found, does not propagate upward
    if (filePath === ENTRY) { fullReload = true; continue; }
    queue.push(...importers.get(filePath));
  }
  return { invalidated, fullReload, state: invalidated.filter((p) => STATE.has(p)) };
}

const name = (p) => p.replace("source/", "");
console.log("graph: " + graph.size + " module, accept boundary: " + [...ACCEPT].map(name).join(", "));
console.log("changed".padEnd(18) + "result".padEnd(14) + "re-evaluated set");
for (const changed of ["source/chart.js", "source/panel.js", "source/format.js",
                       "source/units.js", "source/registry.js", "source/entry-browser.js"]) {
  const r = propagate(changed);
  console.log(name(changed).padEnd(18) +
    (r.fullReload ? "full reload" : "hot update").padEnd(14) +
    r.invalidated.length + "/" + graph.size + "  " + r.invalidated.map(name).join(", ") +
    (r.state.length ? "   [state resets: " + r.state.map(name).join(", ") + "]" : ""));
}
$ node hot.mjs
graph: 13 module, accept boundary: template.js, panel.js, chart.js
changed           result        re-evaluated set
chart.js          hot update    1/13  chart.js
panel.js          hot update    1/13  panel.js
format.js         hot update    4/13  format.js, template.js, csv-export.js, panel.js
units.js          hot update    10/13  units.js, chart.js, alert.js, toolkit.js, forecast.js, scale-registry.js, panel.js, format.js, template.js, csv-export.js
registry.js       hot update    7/13  registry.js, toolkit.js, scale-registry.js, format.js, template.js, csv-export.js, panel.js   [state resets: registry.js]
entry-browser.js  full reload   1/13  entry-browser.js

The table says three things.

A change below the boundary is cheap. Because the chart and panel modules accept their own updates, only they are re-evaluated; twelve of the thirteen modules stay untouched.

A change in a leaf module is expensive. The units module sits low in the graph, and nine modules request it directly or indirectly; all of them are re-evaluated. Small edits to shared utility modules look slow because of this.

The accept boundary’s placement determines the decision. Boundaries are placed at view boundaries, because a view can reproduce its own output. Utility modules cannot be a boundary: they cannot update the bindings held by whatever requests their values.

The Correctness Condition of Hot Replacement

Hot replacement is not an improvement, it is a risk; applied incorrectly, the state the developer sees diverges from the state a cold load would produce.

The first danger is state resetting. When a module holding module-level state is re-evaluated, that state is lost. The table’s last column flags this: when the registry module updates, the formatter registry empties, and if the side-effecting module that fills it does not run again, the interface behaves incompletely. The same silent behavior loss from the tree shaking lesson shows up here, this time during development.

The second danger is stale closures. An event listener or timer registered in a module’s old version keeps running even after the module has been replaced. The accept boundary is obligated to clean up what the old version left behind before switching to the new one.

The third danger is accumulated drift. The state after dozens of hot replacements can differ from the state a cold load of the same source would produce. The rule is to reload the page by hand at every point of doubt and do verification on a cold load.

These three dangers explain why falling back to a full load, in situations where hot replacement cannot be applied, is not a flaw but a safeguard.

The Gap Between Development and Release

Every step development mode drops creates a possibility of a behavior difference between the two modes. If tree shaking is off, a module that should not have been eliminated shows up eliminated only in release. If modules are served as separate files, the evaluation-order difference bundling produces shows up only in release. If minification is off, a name-dependent behavior breaks only in release.

All of these differences share one consequence: working in development mode is not proof of working in release mode. This is why running and trying the release output locally is part of the development flow, and the Preview Deployments lesson in the deployment topic will extend this trial to every change.

Summary

  • Development mode drops bundling, tree shaking, minification, and content hashing; specifier resolution, syntax translation, and source map generation stay.
  • The graph is traversed by the browser’s module loader; the server transforms and hands over a single module, making cost independent of graph size.
  • In the example, first load requests nine of thirteen modules, the panel adds three more, the module behind the dynamic boundary is never requested; the validator dispatches unchanged modules with a bodyless response.
  • Hot replacement is invalidation propagating toward the requesters; the accept boundary stops the propagation, and a full load happens if no boundary is found.
  • A change below the boundary re-evaluates one module, a change in a shared utility module re-evaluates ten; boundaries are placed at view boundaries.
  • Hot replacement carries the risks of state resetting, stale closures, and accumulated drift; a cold load is the measure of verification when in doubt.

Next Step

Development and release modes differ not only in speed but in the environment they run in. Locally, station data comes from a test endpoint; in release, from the real measurement feed; log detail is on locally and off in release; authentication endpoints sit at different addresses. Embedding these differences in the source is not acceptable, because it produces two separate outputs from the same source. What carries the difference is build-time variables. The next lesson shows how these variables enter the output and establishes the one critical rule: a variable that reaches the client is public to everyone.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close