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
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.
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
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:
- Track how many modules were run last pass.
- Run another pass, picking up any newly-ready modules.
- 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
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));
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));
mods[0].run() returns 1, stored in results.x.
mods[1].run(results) adds 1 to that value.
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
}
Use module.deps.every to test every dependency.
The in operator checks whether a key exists in an object.
function isReady(module, results) {
return module.deps.every(d => d in results);
}
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;
}
Wrap the scan in a do-while loop.
Track how many modules ran this pass. If zero, stop.
function runAll(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 results;
}
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);
a.val starts at 1.
b.val becomes 1 * 2 = 2.
c.val becomes 2 + 3 = 5.
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
}
Use the repeated-scan pattern from the lesson.
After the loop, check whether every module name exists in results.
function bundle(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(results);
ran++;
}
}
} while (ran > 0);
if (modules.some(m => !(m.name in results))) {
return {};
}
return results;
}
Recap
- A module is an object with
name,deps, andrun. - A module is ready when all its dependencies are already in the
resultsobject. - 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.