mod_01 - CommonJS Modules¶
Node didn't have ES modules at launch because ES modules didn't exist yet
Ryan Dahl borrowed a module system from ServerJS (which became CommonJS) and that decision shaped the Node ecosystem for a decade. Every file in Node is a module. Variables and functions defined in a file are private to that file unless explicitly exported. This is the foundation of Node's code organization - no global namespace pollution , no IIFE hacks , no script-order dependencies
the module wrapper¶
Before your file runs , Node wraps it in a function
// What you write:
const fs = require('fs')
const path = require('path')
module.exports = { greet }
function greet(name) {
return `hello ${name}`
}
// What Node actually executes:
;(function(exports, require, module, __filename, __dirname) {
const fs = require('fs')
const path = require('path')
module.exports = { greet }
function greet(name) {
return `hello ${name}`
}
})
This wrapper is why __filename and __dirname exist - they're injected parameters , not globals. The wrapper also ensures every file has its own scope. Variables declared with const or let at the top level of a module aren't global - they're scoped to that wrapper function
module.exports vs exports¶
module.exports is what require() actually returns
exports is a reference to module.exports - initially they point to the same object
// These are equivalent at the start:
console.log(exports === module.exports) // true
// Setting properties works the same way:
exports.hello = 'world'
module.exports.hello = 'world'
// Both work because you're modifying the same object
// BUT - reassigning exports breaks the reference:
exports = { hello: 'world' }
// Now exports points to a new object
// module.exports still points to the old object
// Your exports are now empty - nothing gets exported
The assignment trap:
// BAD - reassigns exports , module.exports unchanged
exports = {
findUser: () => {},
createUser: () => {},
}
// require('./user') returns {} - nothing exported
// GOOD - assign to module.exports
module.exports = {
findUser: () => {},
createUser: () => {},
}
// require('./user') returns the object
// ALSO GOOD - add properties to exports
exports.findUser = () => {}
exports.createUser = () => {}
// require('./user') returns { findUser, createUser }
Stick to one pattern per file. Mixing module.exports and exports property assignments is confusing. Pick either module.exports = object for a single export , or exports.name = function for multiple named exports
require() resolution algorithm¶
Node follows a specific order when resolving require('./foo'):
- Core module - check if 'foo' is a built-in module (fs, http, path, etc.)
- Relative path - if starts with './' or '../', resolve relative to the calling file
- node_modules - walk up the directory tree looking for
node_modules/foo
require('fs') // core module - always resolves first
require('./helper') // relative path - same directory
require('../utils/db') // relative path - parent directory
require('express') // node_modules - walks up the tree
When requiring a directory:
flowchart TD
root["./mylib/"] --> idx["index.js<br/><i>required when requiring the directory</i>"]
root --> pkg["package.json<br/><i>if present , main field overrides index.js</i>"] // ./mylib/package.json
{
"name": "mylib",
"main": "lib/entry.js"
}
// require('./mylib') resolves to ./mylib/lib/entry.js
If no main field in package.json and no index.js, the require() throws MODULE_NOT_FOUND
module caching¶
Modules are cached after the first require() - they're singletons
// db.js
console.log('initializing database connection...')
const connection = createConnection('postgres://localhost/mydb')
module.exports = { connection, query: connection.query.bind(connection) }
// app.js
const db1 = require('./db') // logs: 'initializing database connection...'
const db2 = require('./db') // no log - returns cached module
console.log(db1 === db2) // true - same object
This is great for performance: initializing a database connection , loading config files , or parsing templates only happens once. It's bad when you expect a fresh instance on every import. If you need a factory pattern , export a function that creates new instances
// db.js - factory pattern
function createConnection(config) {
const connection = establishConnection(config)
return { query: connection.query.bind(connection) }
}
module.exports = { createConnection }
// app.js - always get a new instance
const { createConnection } = require('./db')
const db = createConnection({ host: 'db-1.example.com' })
cache manipulation¶
You CAN modify the require cache - but that doesn't mean you should
// BAD - clear cache and reload (for hot-reloading in development)
delete require.cache[require.resolve('./config')]
const freshConfig = require('./config')
// If you need hot reloading , use tools built for it:
// nodemon , node --watch (Node 18+) , or pm2
Manually manipulating require.cache in production is a bug factory. Other modules may still hold references to the old cached object. Event listeners from the old module still fire. Don't do it
module scope¶
Everything in a module is private unless explicitly exported
// secrets.js
const masterPassword = 'hunter2' // private - not exported
const apiKey = process.env.API_KEY // still private
function encrypt(data) {
return crypto.createHmac('sha256', masterPassword).update(data).digest('hex')
}
module.exports = { encrypt }
// app.js
const { encrypt } = require('./secrets')
console.log(encrypt('hello')) // works
console.log(masterPassword) // ReferenceError - not exported
Security note: Just because a variable isn't exported doesn't mean it's safe. The module's internal state is still accessible via closures in exported functions. And if someone can access the module's source code (like in open source) , your "secret" is right there. Don't hardcode secrets in modules - use environment variables or a secrets manager
circular dependencies¶
Node handles circular dependencies with a partial module cache
When module A requires B , and B requires A (directly or indirectly) , Node returns whatever module.exports contains at that point in A's execution
// a.js
console.log('a starting')
exports.done = false
const b = require('./b')
console.log('in a , b.done =', b.done)
exports.done = true
console.log('a done')
// b.js
console.log('b starting')
exports.done = false
const a = require('./a')
console.log('in b , a.done =', a.done) // a.done is FALSE - not yet set
exports.done = true
console.log('b done')
// main.js
console.log('main starting')
const a = require('./a')
console.log('in main , a.done =', a.done)
console.log('main done')
// Output:
// main starting
// a starting
// b starting
// in b , a.done = false <-- a hasn't finished loading!
// b done
// in a , b.done = true
// a done
// in main , a.done = true
In b.js , a.done is false because a.js hasn't finished executing when b requires it. Node returns whatever A had exported so far - the incomplete module
How to avoid: Don't design circular dependencies. They're a code smell that indicates your modules have overlapping responsibilities. If you find yourself with a circular dependency , extract the shared code into a third module that both A and B import
// common.js - extract shared code
exports.sharedFunction = () => {}
// a.js
const { sharedFunction } = require('./common')
exports.a = () => sharedFunction()
// b.js
const { sharedFunction } = require('./common')
exports.b = () => sharedFunction()
security: require() path traversal¶
require() follows filesystem paths relative to the calling file
If you build require paths from user input , you open a Local File Inclusion (LFI) vulnerability
// BAD - user input in require path
app.get('/api/:module', (req, res) => {
const mod = require(`./modules/${req.params.module}`)
res.json(mod.getData())
})
// GET /api/../../../etc/passwd -> LFI
// GOOD - validate against a whitelist
const allowedModules = { users: './modules/users', posts: './modules/posts' }
app.get('/api/:name', (req, res) => {
const modPath = allowedModules[req.params.name]
if (!modPath) return res.status(404).json({ error: 'not found' })
const mod = require(modPath)
res.json(mod.getData())
})
Never interpolate user input into require paths. Use a whitelist map or validate against a known set of module names. The same applies to require.resolve() and import() calls
summary¶
- Every file is a module wrapped in a function - variables are scoped locally
module.exportsis whatrequire()returns;exportsis an alias reference- Reassigning
exportsbreaks the reference - assign tomodule.exportsinstead require()resolves: core modules first , then relative paths , then node_modules- Modules are cached as singletons after first load
- Circular dependencies return incomplete exports - refactor shared code out
- Never interpolate user input into
require()paths - that's LFI
prerequisites¶
async_04_error_handling.md - error handling patterns
next -> mod_02_es_modules.md