C.W.K.
Stream
Lesson 05 of 05 · published

Bundler and Build — esbuild, TypeScript, the dist/ Pipeline

~13 min · bundler, esbuild, typescript, build

Level 0Extension Curious
0 XP0/54 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Vanilla JS for one file is fine. Multi-file with npm dependencies and TypeScript is where you reach for a bundler. Lesson 5 is the minimum esbuild setup that turns src/ into dist/ in under a second, with sourcemaps, watch mode, and Web Store-clean output."

Why a Bundler

Up to about 200 lines per file, vanilla JS scattered across content.js / background.js / popup.js / panel.js / etc. is fine. The bundler earns its keep when:

  • You want TypeScript for types.
  • You import a third-party npm package with internal ES module imports (anything from npm that's not a single flat file).
  • Your codebase has shared helpers used by multiple entry points (popup + panel + content all need the same clip-row renderer).
  • You want one source layout in src/, one production layout in dist/, with the manifest and HTML copied/transformed during build.

esbuild as the Default

esbuild compiles JS/TS in milliseconds. For an extension that's mostly TypeScript with a few entry points, the entire build is:

esbuild \
  src/background.ts \
  src/content.ts \
  src/popup.ts \
  src/panel.ts \
  --bundle \
  --outdir=dist \
  --target=chrome120 \
  --sourcemap=inline \
  --format=iife

Four entry points, one command, four output files (background.js, content.js, popup.js, panel.js) all bundled. Output is IIFE format which is what Chrome wants for content scripts and classic background pages. --target=chrome120 tells esbuild which JS features it can leave un-transpiled.

The Layout

clipdeck/
  src/
    background.ts
    content.ts
    popup.ts
    panel.ts
    shared/
      clip-row.ts
      escape-html.ts
      types.ts
  static/
    manifest.json
    popup.html
    panel.html
    icons/
      16.png 32.png 48.png 128.png
    vendor/
      Readability.js
  scripts/
    build.mjs
  dist/         # gitignored, generated
  package.json
  tsconfig.json

The build script copies static/ into dist/ and bundles src/ into dist/. After npm run build you have a chrome-loadable dist/.

The Build Script

esbuild's Node API gives you a single file you can run with node scripts/build.mjs:

import { build } from 'esbuild';
import { copyFile, mkdir, cp } from 'fs/promises';

await mkdir('dist', { recursive: true });
await cp('static', 'dist', { recursive: true });
await build({
  entryPoints: ['src/background.ts', 'src/content.ts', 'src/popup.ts', 'src/panel.ts'],
  bundle: true,
  outdir: 'dist',
  target: 'chrome120',
  format: 'iife',
  sourcemap: 'inline',
  logLevel: 'info',
});

Add a --watch flag and esbuild's context API to get rebuild-on-save during development. Pair with a Chrome extension reload library (extension-reloader, or a tiny chrome.runtime.reload trigger via your dev SW) so saving a file triggers Chrome to reload the extension.

TypeScript Setup

tsconfig.json for an extension:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["chrome"],
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Install @types/chrome for chrome.* API types. noEmit: true because esbuild does the actual compilation; tsc only runs for type-checking. Add an npm run typecheck script that runs tsc --noEmit as a separate check.

The package.json Scripts

{
  "scripts": {
    "build": "node scripts/build.mjs",
    "watch": "node scripts/build.mjs --watch",
    "typecheck": "tsc --noEmit",
    "package": "npm run typecheck && npm run build && cd dist && zip -r ../clipdeck.zip ."
  },
  "devDependencies": {
    "esbuild": "^0.25.0",
    "typescript": "^5.5.0",
    "@types/chrome": "^0.0.300"
  }
}

npm run package typechecks, builds, and zips in one shot. That zip is what you upload to the Web Store. npm run watch is your dev loop; pair with chrome://extensions auto-reload (browser extension or a tiny chrome.runtime.reload SW listener).

Migration From Vendor

Track 7's Readability.js was vendored as a flat file. With a bundler, you can npm install @mozilla/readability and import it normally:

// src/content.ts
import { Readability } from '@mozilla/readability';
// ...use exactly as before

The bundled content.js will inline Readability's code. Cleaner, version-tracked, tree-shaken where possible. The static/vendor/ directory becomes unused — delete it from the build output.

Vanilla JS for one file. esbuild + TS + the npm ecosystem when you outgrow that. The dist/ output is what Chrome loads and what you zip for the Web Store. Static manifest + HTML + icons get copied as-is; src/ gets bundled.
Vite as an alternative. Vite has an extension plugin (vite-plugin-web-extension) that handles the manifest transform and dev-server hot reload nicely. esbuild is enough for ClipDeck's scope; Vite shines when you want HMR for popup/panel UI during development. Both produce a valid dist/. Pick whichever matches your other tooling.

Code

scripts/build.mjs — esbuild via Node API with optional watch mode·javascript
// scripts/build.mjs — single-file build script using esbuild's Node API
import { build, context } from "esbuild";
import { cp, mkdir, rm } from "fs/promises";

const watch = process.argv.includes("--watch");

await rm("dist", { recursive: true, force: true });
await mkdir("dist", { recursive: true });
await cp("static", "dist", { recursive: true });

const options = {
  entryPoints: [
    "src/background.ts",
    "src/content.ts",
    "src/popup.ts",
    "src/panel.ts",
  ],
  bundle: true,
  outdir: "dist",
  target: "chrome120",
  format: "iife",
  sourcemap: "inline",
  logLevel: "info",
};

if (watch) {
  const ctx = await context(options);
  await ctx.watch();
  console.log("[ClipDeck build] watching src/ — Ctrl+C to stop");
} else {
  await build(options);
  console.log("[ClipDeck build] dist/ ready");
}
package.json — scripts + dev dependencies for the ClipDeck build·json
{
  "name": "clipdeck",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "node scripts/build.mjs",
    "watch": "node scripts/build.mjs --watch",
    "typecheck": "tsc --noEmit",
    "package": "npm run typecheck && npm run build && cd dist && zip -r ../clipdeck.zip ."
  },
  "devDependencies": {
    "esbuild": "^0.25.0",
    "typescript": "^5.5.0",
    "@types/chrome": "^0.0.300",
    "@mozilla/readability": "^0.6.0"
  }
}
tsconfig.json — strict TypeScript with chrome.* types·json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "types": ["chrome"],
    "noEmit": true,
    "skipLibCheck": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}

External links

Exercise

Convert ClipDeck from vanilla JS to a bundled TS project. Move all .js source into src/ with .ts extensions. Move manifest.json + popup.html + panel.html + icons/ into static/. Add scripts/build.mjs (first code block), package.json (second), tsconfig.json (third). Run npm install, then npm run build. The dist/ folder should contain manifest.json + the bundled .js files + html + icons. In chrome://extensions, swap your 'Load unpacked' target from clipdeck/ to clipdeck/dist/. Reload — everything should work as before. Then make a small TypeScript change (add a type annotation, intentionally pass a wrong type to chrome.storage.local.set) and run npm run typecheck — confirm tsc catches the error. Run npm run package to produce the upload-ready zip.
Hint
If npm run build errors with 'Could not resolve @mozilla/readability', npm install didn't include the package — confirm it's in devDependencies and run npm install again. If the bundled content.js fails to load in the page with 'Cannot find name chrome', tsc isn't picking up @types/chrome — confirm "types": ["chrome"] is in tsconfig.json. If the popup fails after the bundler conversion, check that you also updated the popup.html <script src="popup.js"> if you renamed the entry — esbuild outputs based on entryPoints names, so src/popup.ts becomes dist/popup.js.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.