Module 10 · Capstone - Real-World Projects ⏱ 20 min

Project — Module Bundler Simulation

By the end of this lesson you will be able to:
  • Represent modules as objects with names, dependencies, and executable bodies
  • Resolve a dependency graph and detect circular dependencies
  • Execute modules in dependency order using a simple ready-queue algorithm

Modern JavaScript is split across hundreds of files. A module bundler reads those files, figures out which one needs to run first, and stitches them into a single file the browser can load. Tools like Webpack and Rollup do exactly this, and the algorithm at their heart is surprisingly small — small enough that you can build a simulation in one lesson.

In this project you will simulate a minimal bundler. You are given an array of module objects. Each module has a name, a deps array of names it depends on, and a run function that produces its output. Your bundler must decide the correct execution order, run each module once its dependencies are ready, and detect circular dependencies before they loop forever.

There is no DOM and no network here — just objects, arrays, and logic. The skills you use — looping, testing conditions, and building objects — are the same ones you have been practising all course.

flowchart TD
  M1["math.js<br/>deps: []"] --> M2["utils.js<br/>deps: [math]"]
  M2 --> M3["app.js<br/>deps: [utils]"]
  style M1 fill:#e0900b,color:#fff
  style M2 fill:#e0900b,color:#fff
  style M3 fill:#e0900b,color:#fff
A simple dependency chain: math has no deps, utils needs math, and app needs utils.

The module shape

A module is just an object:

{
  name: "math",
  deps: [],
  run: () => ({ add: (a, b) => a + b })
}

The run function returns whatever the module wants to export. The bundler collects these exports in a results object keyed by name. When app depends on math, the bundler passes results.math into app.run so it can call math.add.

This separation — deps declares what it needs, run uses it — is the same contract real bundlers rely on. Keeping the declaration and the execution separate makes the dependency graph visible, which is exactly what the bundler needs to do its job.

Two modules: math exports a function, app consumes it through the results object.
const math = {
  name: "math",
  deps: [],
  run: () => ({ add: (a, b) => a + b })
};

const app = {
  name: "app",
  deps: ["math"],
  run: (results) => {
    console.log(results.math.add(2, 3));
  }
};

const results = {};
results.math = math.run();
app.run(results);

Resolving dependencies

The core question is: which module can run right now? A module is ready when every name in its deps array already exists as a key in the results object. If a module has no dependencies, it is ready immediately.

The simplest algorithm is a loop that repeatedly scans the modules, finds one that is ready and not yet run, runs it, and stores the result. If a full scan finds nobody ready, then either everything is done or a circular dependency exists. This scan-and-run pattern is not the fastest algorithm, but it is the easiest to understand, and understanding it teaches you what the fast algorithms are actually optimising.

flowchart TD
  S["Start"] --> L["Find ready module"]
  L --> R{"All deps in results?"}
  R -- Yes --> X["Run and store"]
  X --> L
  R -- No --> D{"Any left?"}
  D -- Yes --> L
  D -- No --> F{"All done?"}
  F -- Yes --> E["Finish"]
  F -- No --> C["Circular!"]
  style C fill:#b91c1c,color:#fff
  style E fill:#1e7f3d,color:#fff
The bundler loop: find a ready module, run it, and repeat. No ready modules means finish or detect a cycle.
A single pass only catches modules that are ready in array order. 'a' is skipped because 'b' is not ready yet.
const modules = [
  { name: "a", deps: ["b"], run: () => "A" },
  { name: "b", deps: [], run: () => "B" }
];

function isReady(mod, results) {
  return mod.deps.every(d => d in results);
}

const results = {};
for (const m of modules) {
  if (isReady(m, results)) {
    results[m.name] = m.run();
  }
}
console.log(results.b);
console.log(results.a);

The ready-queue loop

A single pass is not enough. If a depends on b, and b appears after a in the array, the first pass skips a, runs b, and stops. You need a loop that keeps scanning until nothing new can run.

The pattern is:

  1. Track how many modules were run last pass.
  2. Run another pass, picking up any newly-ready modules.
  3. If a pass runs zero modules but some are still pending, you have a circular dependency.

This loop is the heart of every bundler. Real tools use a topological sort; your simulation uses the same idea with a simpler scan. The important insight is that order is determined by readiness, not by the order the modules appear in the array.

flowchart LR
  A["A"] --> B["B"]
  B --> A
  style A fill:#b91c1c,color:#fff
  style B fill:#b91c1c,color:#fff
A circular dependency: A needs B and B needs A, so neither can ever run first.
Detecting a cycle: after the loop finishes, compare how many ran to how many exist.
const modules = [
  { name: "a", deps: ["b"], run: () => "A" },
  { name: "b", deps: ["a"], run: () => "B" }
];

function hasCycle(modules) {
  const results = {};
  let ran;
  do {
    ran = 0;
    for (const m of modules) {
      if (!results[m.name] && m.deps.every(d => d in results)) {
        results[m.name] = m.run();
        ran++;
      }
    }
  } while (ran > 0);
  return modules.length > Object.keys(results).length;
}

console.log(hasCycle(modules));
Exercise

Predict the output. x has no deps, so it runs first. Then y uses the result.

const mods = [
  { name: "x", deps: [], run: () => 1 },
  { name: "y", deps: ["x"], run: (r) => r.x + 1 }
];
const results = {};
results.x = mods[0].run();
console.log(mods[1].run(results));
Exercise

Write isReady(module, results) that returns true if every dependency string in module.deps is a key in the results object.

function isReady(module, results) {
  // return true if all deps are in results
}
Exercise

This runAll function runs modules in a single pass, so it misses dependencies that appear later in the array. Fix it so it repeatedly scans until no more modules can run.

function runAll(modules) {
  const results = {};
  for (const m of modules) {
    if (m.deps.every(d => d in results)) {
      results[m.name] = m.run();
    }
  }
  return results;
}
Exercise

Predict the output. The modules are in dependency order, so a single pass runs them all. Track the values through the chain.

const mods = [
  { name: "a", deps: [], run: () => ({ val: 1 }) },
  { name: "b", deps: ["a"], run: (r) => ({ val: r.a.val * 2 }) },
  { name: "c", deps: ["b"], run: (r) => ({ val: r.b.val + 3 }) }
];
const results = {};
for (const m of mods) {
  if (m.deps.every(d => d in results)) {
    results[m.name] = m.run(results);
  }
}
console.log(results.c.val);
Exercise

Transfer task. Write bundle(modules) that executes every module in dependency order and returns the final results object. It must handle out-of-order arrays. If a circular dependency makes it impossible to run every module, return an empty object {}.

function bundle(modules) {
  // return results object, or {} on cycle
}

Recap

  • A module is an object with name, deps, and run.
  • A module is ready when all its dependencies are already in the results object.
  • A repeated scan executes modules in dependency order no matter their array position.
  • A full scan that runs zero modules but leaves some pending signals a circular dependency.
  • Real bundlers use the same logic, optimised with graphs and caches.

Next you will model asynchronous API calls using only Promises and in-memory data.

Checkpoint quiz

When is a module ready to run?

What does a repeated scan with zero new modules indicate?

Go deeper — technical resources