117: Node Modules, `package.json`, npm, Semantic Versioning, and Workspaces
Learning objectives
You will learn to:
- distinguish ES modules and CommonJS;
- configure an ESM-first Node project;
- import/export local code and built-in modules;
- resolve relative paths safely in ESM;
- understand
package.json,package-lock.json, dependencies, devDependencies, scripts, and engines; - install, update, audit, and execute packages;
- read semantic-version ranges;
- explain local versus global packages;
- use
npx/npm exec; - create package entry points with
exports; - understand npm workspaces and monorepo basics;
- avoid dependency and supply-chain mistakes.
Start with an ESM project
mkdir node-course
cd node-course
npm init -y
Edit:
{
"name": "node-course",
"version": "1.0.0",
"private": true,
"type": "module"
}
Now .js files in this package are interpreted as ES modules.
ES modules
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
console.log(add(2, 3));
Run:
node app.js
Node ESM relative imports normally include file extensions.
Default exports
export default function createLogger() {
...
}
Import:
import createLogger from './logger.js';
Prefer named exports for modules with several public capabilities because refactoring and discovery can be clearer.
CommonJS
Older Node code often uses:
const fs = require('node:fs');
module.exports = {
...
};
CommonJS still exists and many packages use it.
Do not mix syntaxes casually.
Understand interoperability, but choose one module system per package unless publishing requires careful dual-package design.
ESM metadata
CommonJS traditionally has:
__filename __dirname
ESM has:
console.log(import.meta.url);
Modern Node also exposes convenient import.meta path helpers in supported releases; where portability matters, URLs remain a clear baseline.
Classic conversion:
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
This is different from:
process.cwd()
Module directory answers:
Where is this module file?
Current working directory answers:
From which directory was this process launched?
Those can differ.
Dynamic import
const module = await import('./optional-feature.js');
Useful for:
- lazy optional features;
- conditionally loaded plugins;
- large modules not always needed.
Do not use dynamic import as a substitute for normal architecture.
package.json
A production package can include:
{
"name": "@acme/task-api",
"version": "2.3.1",
"private": true,
"type": "module",
"engines": {
"node": ">=24 <27"
},
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js",
"test": "node --test",
"lint": "eslint ."
},
"dependencies": {},
"devDependencies": {}
}
Fields have different responsibilities.
private: true prevents accidental publication through npm.
Dependencies versus devDependencies
Install runtime package:
npm install express
Usually recorded under:
"dependencies"
Development-only tooling:
npm install --save-dev eslint
under:
"devDependencies"
Do not place a package in devDependencies if production runtime imports it and your deployment installs only production dependencies.
Lockfile
package-lock.json records resolved dependency graph information.
Commit it for applications.
Benefits:
- repeatable installs;
- explicit transitive versions;
- integrity metadata;
- deterministic CI behavior.
Use:
npm ci
in CI/deployment environments where the lockfile should be installed exactly.
npm ci fails when package metadata and lockfile disagree rather than silently rewriting the lockfile.
npm scripts
{
"scripts": {
"start": "node src/server.js",
"test": "node --test",
"check": "npm run lint && npm test"
}
}
Run:
npm start
npm test
npm run check
Local package executables installed in node_modules/.bin are available to npm scripts.
You normally do not need a global install for project tooling.
Local versus global installation
Local:
npm install eslint --save-dev
belongs to project dependency graph.
Global:
npm install -g some-cli
installs a CLI for the user/system environment.
Project build tooling should normally be local so:
- CI gets same version;
- team does not depend on machine-specific global state;
- package version is documented.
npx / npm exec
Run a package executable without a permanent global install:
npx eslint .
or use modern npm exec forms.
Be cautious when running a package name you have not reviewed. Executing an npm package executes code on your machine.
Semantic Versioning
Version:
MAJOR.MINOR.PATCH
Conceptually:
PATCH bug-compatible fix MINOR backwards-compatible feature MAJOR breaking change
Common ranges:
"express": "^5.1.0"
Caret generally allows compatible updates below the next major for normal 1.0.0+ packages.
Tilde:
"some-package": "~2.4.3"
is narrower.
Exact:
"some-package": "2.4.3"
allows no range.
The lockfile still records the concrete installed version.
Do not assume all ecosystem packages follow SemVer perfectly. Read migration notes for major upgrades.
Inspect dependency graph
npm ls
Outdated:
npm outdated
Package metadata:
npm view express version
Explain why a package exists:
npm explain some-package
Commands evolve with npm; verify help for your installed version.
Security audit
npm audit
Treat audit output as input to engineering judgment.
Do not blindly run destructive force upgrades that introduce breaking majors.
Evaluate:
- vulnerable package;
- whether vulnerable code path is used;
- patch/minor/major update;
- upstream status;
- compensating controls;
- deployment exposure.
Keep dependencies current.
Supply-chain risk
Installing a package means trusting its code and dependency graph.
Before adding a package, ask:
- can built-in Node APIs solve this?
- is project maintained?
- release cadence?
- package ownership changes?
- download/popularity is not proof of safety;
- dependency count?
- license?
- TypeScript/types if needed?
- security history?
For tiny utility behavior, a built-in function may be safer than adding 40 transitive dependencies.
exports
Published/internal packages can define supported entry points:
{
"name": "@acme/shared",
"type": "module",
"exports": {
".": "./src/index.js",
"./errors": "./src/errors.js"
}
}
Consumers:
import { something } from '@acme/shared';
import { AppError } from '@acme/shared/errors';
exports creates an explicit public surface and can prevent consumers from depending on internal files.
imports
A package can define private import aliases:
{
"imports": {
"#config": "./src/config.js",
"#lib/*": "./src/lib/*.js"
}
}
Usage:
import { config } from '#config';
Use sparingly and consistently.
Package entry design
Avoid a “barrel” that eagerly imports the entire application:
// index.js imports database, server, jobs, workers...
when consumers only need one small utility.
Module boundaries affect startup cost, side effects, tree structure, and test isolation.
Side effects during import
Avoid:
// db.js
connectToDatabase();
as an implicit import side effect unless architecture deliberately expects it.
Prefer:
export async function connectDatabase() {
...
}
and call during application composition.
This makes tests and startup ordering easier.
npm workspaces
Root:
{
"name": "platform",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
]
}
Structure:
platform/ ├─ apps/ │ ├─ api/ │ └─ worker/ └─ packages/ ├─ config/ └─ domain/
Workspaces help manage multiple packages in one repository.
Use them when there is a real multi-package boundary, not to make a small app look “enterprise.”
Workspace dependency boundaries
A shared package should not import back into an application:
packages/domain ↓ apps/api
not:
packages/domain → apps/api/internal-controller
Keep dependency direction acyclic and understandable.
Publishing basics
A reusable package needs:
- intentional public API;
- versioning;
- license;
- files included in package;
- build output if compiled;
- tests;
- changelog/release process.
Application-only code can stay "private": true.
Common mistakes
Missing .js extension in ESM relative import
import './config';
may fail depending on Node resolution context.
Use explicit:
import './config.js';
Confusing cwd and module directory
Tests and process managers can launch from different working directories.
Installing project tools globally
Creates hidden machine state.
Deleting lockfile to “fix” dependency conflicts
Usually hides the underlying version problem.
Running npm audit fix --force without review
Can introduce breaking upgrades.
Import-time database/server startup
Creates test and lifecycle problems.
Exercises
- Convert a CommonJS two-file example to ESM.
- Print module URL and process cwd, then launch script from another directory.
- Create npm scripts for dev/test/start.
- Install one runtime and one development dependency.
- Inspect the lockfile and
npm ls. - Create a package with an
exportsmap. - Build a two-workspace monorepo with one shared package.
- Review five dependencies and justify whether each should exist.
Mastery checklist
Explain:
- ESM versus CommonJS;
- module location versus cwd;
- dependencies versus devDependencies;
- lockfile and
npm ci; - SemVer ranges;
- local versus global packages;
exports;- workspaces;
- supply-chain risk;
- why import-time side effects complicate systems.
