Skip to content
System nominalUTC --:--:--Articles 041
MODULE 06 Lesson 18 / 18 55 MIN

Building an application

Testing, Bundling and Deploying

Test the DOM layer with jsdom, write a bundler from first principles, then set up the production build settings and content hashes that make a static deploy fast.

The application works in the tab you have been reloading. Now it has to work when someone else loads it, on a machine you cannot open, from a file whose name contains a hash you have not chosen. That is three jobs: tests that run without a human, a build that turns many modules into few files, and a deploy that puts them somewhere with the right caching.

Tests come first, because they make the other two safe to repeat.

jsdom runs the DOM in Node, and it is not a browser

There are two ways to test the DOM layer. A headless browser drives a real browser engine over a protocol. jsdom is a JavaScript implementation of the DOM with no rendering engine, installed as a normal dependency and run in-process.

I used jsdom, version 30.1.3, on Node 24.11.1, because it installs with npm install and runs wherever the tests run. You get the API: elements, events, attributes, selections. You do not get layout, painting, or real input. There is no offsetHeight worth reading, no requestAnimationFrame unless you construct the DOM with pretendToBeVisual, and no way to catch a bug that lives in the layout engine. So test behaviour in jsdom and rendering in a real browser, rarely, because it needs a browser download in CI.

A page in a test is four lines

import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";

export function withPage(html) {
  const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, {
    url: "https://example.test/",
  });
  return { dom, document: dom.window.document, window: dom.window };
}

The url matters more than it looks. Without it jsdom uses about:blank, and anything touching localStorage, cookies or same-origin checks fails with an opaque error. Events have to come from the same window as the element, because new MouseEvent(...) from Node's globals is a different class from the one jsdom expects.

const { document, window } = withPage(`<div id="app"></div>`);
state.items = [{ id: "1", text: "milk", done: false }];
mount(document.querySelector("#app"));

document
  .querySelector("#list li")
  .dispatchEvent(new window.MouseEvent("click", { bubbles: true }));

assert.equal(state.items[0].done, true);
$ node --test "test/*.test.js"
✔ renders one row per visible item (53.781916ms)
✔ a click on a row toggles it and re-renders (9.643375ms)
✔ the filter button changes which rows render (8.691416ms)
✔ destroy removes the delegated listener (6.010125ms)
✔ an AbortSignal from the page detaches the listener (6.396208ms)
✔ innerHTML is never fed untrusted text (6.482417ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 476.721292

bubbles: true is required for delegated listeners, because without it the event never reaches the container. node --test with a glob runs the files and exits non-zero when something fails, which is what a CI step needs. The last test is worth copying: it renders a row whose text is <img src=x onerror="pwn()"> and asserts no img exists in the row.

Reset module state between tests

A module-level state object is shared by every test in the file, so a test that adds an item changes what the next one sees and the failure appears in the wrong test. Export a factory that returns fresh state, or reset it in beforeEach.

A bundler is a graph walk and a wrapper

Every bundler does the same three things. It reads the entry file and follows each import to the file it names, building a graph. It rewrites those imports into something the target runtime can resolve. Then it wraps the modules in a function registry and emits one file that calls the entry module.

The graph walk is short. Keep a map from file path to module record, and recurse: read a file, collect the relative specifiers it imports, and give each new path the next id. Reuse the id when a path is already in the map, or two modules importing the same helper get two separate copies of its state. Replace each specifier with the number for its path, emit an object keyed by those numbers whose values are functions taking (module, exports, require), and add one require that calls module 0, the entry.

Run it on a three-module application and it names every file in the graph, then emits a bundle that runs:

$ node bundler.mjs src/main.js dist/bundle.js
src/main.js
src/cart.js
src/format.js

$ node dist/bundle.js
$12.50

The output has one object whose keys are module ids, and require looks up the registry and calls the module. That is the whole trick.

Four limitations are worth naming. The regex only matches import { a, b } from "...", so default imports, namespace imports, re-exports and dynamic import() break. Bare specifiers from node_modules throw, because resolving them means reading package.json files and walking up the directory tree. There is no tree shaking, so an unused export stays in the bundle. Circular imports are unhandled, which would leave undefined at the point of use.

type="module" plus an import map covers more than it used to

Browsers have supported ES modules natively since 2017, so nothing needs building for a browser to follow <script type="module" src="./main.js"> and load what it imports. What native modules cannot do is resolve a bare specifier: import { createElement } from "react" has nothing to resolve against, because the browser does not read node_modules. An import map fills that gap by giving every bare specifier a URL.

<script type="importmap">
{
  "imports": {
    "preact": "/vendor/preact.module.js",
    "preact/hooks": "/vendor/preact-hooks.module.js"
  }
}
</script>
<script type="module" src="/app/main.js"></script>

Add that and a static host and you have a working application with no build step. Requests are one per module, so a fifty-module app is fifty requests; on HTTP/2 that is less painful than it sounds.

The toolchain earns its keep in three situations. TypeScript needs compiling, because browsers do not run .ts files. A dependency tree with hundreds of modules needs bundling to cut the request count, and some published packages ship CommonJS that a browser will not load as a module. A production deploy wants minified output with content hashes, which means a build step by definition. If none of those applies, type="module" with an import map is a complete strategy, and trying it costs an afternoon.

Production build settings that earn their keep

Four settings matter; the rest is preference. A real build with esbuild produced the numbers below on Node 24.11.1:

const result = await build({
  entryPoints: ["src/main.js"],
  bundle: true,
  minify: true,
  sourcemap: true,
  format: "iife",
  target: ["es2022"],
  define: { "process.env.NODE_ENV": '"production"' },
  metafile: true,
  outfile: "dist/main.js",
});
$ node build.mjs src/main.js dist
dev  src/main.js: 400 bytes unminified
prod src/main.js: 211 bytes minified
asset: dist/main.a99f42a71313.js (211 bytes)
  input src/format.js
  input src/cart.js
  input src/main.js

Minify. Whitespace, comments and local names go, and dead branches disappear. That run was 400 bytes to 211 on a file small enough for fixed overhead to dominate; application-sized code typically drops 40 to 60 percent.

Source maps. A minified stack trace names a.js:1:2143. With a map and a sourceMappingURL at the end of the file, the browser and any error reporter map it back to your file and line. Emit them and upload them to the tracker, or keep them off the public origin.

Tree shaking. The bundler drops exports nobody imports, which only works when the module has no side effects. Add a second entry point that imports one more export from the same module and build both:

$ node build.mjs src/tree-entry.js dist
dev  src/tree-entry.js: 347 bytes unminified
prod src/tree-entry.js: 185 bytes minified
asset: dist/main.2a4e8e01360d.js (185 bytes)

$ node build.mjs src/tree-entry-full.js dist
dev  src/tree-entry-full.js: 454 bytes unminified
prod src/tree-entry-full.js: 237 bytes minified
asset: dist/main.8b6be1e4756d.js (237 bytes)

The only difference between those entry points is one extra import { formatPercent } from "./format.js", and it costs 52 bytes. Without tree shaking, both bundles would carry the function. It cannot cross a side effect: if a module calls something at the top level, the whole module stays. That is why packages advertise a sideEffects field in package.json.

Environment variables. define replaces the text process.env.NODE_ENV with the string "production" at build time, and that literal lets minifiers delete development-only branches inside libraries. A browser has no process object, so an unreplaced reference is a runtime error. metafile: true is the quiet useful one: it reports the size of every input and output, which turns "the bundle grew" into "this dependency grew".

Content hashes let you cache forever

A filename containing a hash of the file's contents is immutable by construction. If the contents change, the name changes, so a cache can hold the old copy for a year without serving stale code. HTML is the one file that must not be cached for long, because it names the hashed assets.

const source = await readFile("dist/main.js");
const digest = createHash("sha256").update(source).digest("hex").slice(0, 12);
await copyFile("dist/main.js", `dist/main.${digest}.js`);
$ node hash-demo.mjs
main.js -> main.5baa6bf14498.js
main.js -> main.8b3da6fb3dca.js

Those two lines are the same file before and after one character changed in it. The hash is a function of the bytes, so a trivial change produces a completely different name.

The header pair that follows: hashed assets get Cache-Control: public, max-age=31536000, immutable, and HTML gets Cache-Control: no-cache, which permits storing a copy but requires revalidation. Deploying is then a directory swap, because the new HTML names new files.

The failure to avoid is hashing some assets and not others. A stylesheet referenced as main.css from a page cached for a year serves last month's design to a returning visitor.

What the course built, and what it left out

The course started with values and coercion, then scope and the temporal dead zone, then the event loop: three lessons on why code that looks sequential is not. Collections and iteration came next, where the array methods and Map decide both what the code says and how it performs. Then functions, closures and modules, which is where state starts living somewhere deliberate instead of on window. Asynchrony took a full module, from callbacks to promises to async and await, because most production bugs are ordering bugs.

TypeScript came next as a tool for finding mistakes: structural typing and narrowing, generics and utility types, then a tsconfig whose strict flags each catch a class of error. This module put an interface on all of it: nodes, state, rendering, and a build whose caching does not depend on luck.

Building it without a framework is what makes the rest legible. When a component re-renders and your input loses focus, you know which mechanism did it, and those bugs stop being mysterious once you have written the thing yourself.

Lab 60 minutes

Take the todo application from the previous lesson and turn it into something deployable.

Tests. Write five with node --test and jsdom: adding an item renders a row; clicking a row toggles it; the filter hides the right rows; destroy() stops clicks from changing state; a label containing <b> renders as text and creates no element. Stop the shared state leaking between tests first.

Build. Add a script that bundles the entry, minifies, emits a source map, replaces process.env.NODE_ENV, and copies the output to a filename containing a twelve-character content hash. Print the dev size, the production size and the hashed name.

Deploy. Generate the HTML so it references the hashed file, serve the directory, and load it. Confirm the app works, confirm the source map resolves a deliberate error back to your source line, and confirm that adding one character to a source file changes the hash.

Then answer in writing: which of your five tests still passes if the render uses innerHTML with the item text?

Show the solution and the reasoning

Order matters, so take the three parts separately.

The state reset comes first, because tests written before it look flaky. The fix is a store factory: createStore(initial) closes over its own items and filter, exposes them through getters, and mutates them through add, toggle and setFilter. Each test calls it and gets state no other test can reach. Copying the module-level store into a test file is what produces order-dependent failures.

For the build, hash the file after minification, because that is the file that gets served. Hashing the source produces a name that stays the same when a comment changes and changes when a dependency does, which is the wrong way round.

const source = await readFile("dist/main.js");
const digest = createHash("sha256").update(source).digest("hex").slice(0, 12);
await copyFile("dist/main.js", `dist/main.${digest}.js`);
await writeFile("dist/index.html", `<!doctype html>
<div id="app"></div>
<script type="module" src="./main.${digest}.js"></script>
`);

Generating the HTML in the same script is the part people skip. A hand-maintained index.html with a hashed filename in it goes stale on the first build nobody updates by hand, and the page 404s while the CDN serves the old copy.

The final question is worth checking rather than reasoning about. Replace li.textContent = item.text with li.innerHTML = item.text and run the suite: five tests still pass and the untrusted-text test is the only failure.

$ node --test "test/*.test.js"
✔ renders one row per visible item (52.489208ms)
✔ a click on a row toggles it and re-renders (10.654583ms)
✔ the filter button changes which rows render (9.051459ms)
✔ destroy removes the delegated listener (5.879291ms)
✔ an AbortSignal from the page detaches the listener (8.661292ms)
✖ innerHTML is never fed untrusted text (7.986042ms)
ℹ tests 6
ℹ pass 5
ℹ fail 1

That is the shape of a weak suite. A test asserting textContent equals the label passes under innerHTML, because the visible text is identical, and a test clicking a row and checking state passes because the handler still runs. Only a test asserting on structure, here that no element was created from the payload, can tell the two apart. One test in six is thin coverage for the difference between safe and exploitable, and it is the one people leave out.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of JavaScript and TypeScript, from Script to Application. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.