Node.js · lesson 3 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
package.json, npm Scripts, and node --watch
Run npm scripts, install a dependency and read the caret it adds to package.json, then watch node --watch detect a real file change and restart.
By SkillByExample · Published
Every Node project starts with the same file: package.json. Node is 26.12.1 and npm is 11.20.0 for every command below (Docker’s node:26 image).
scripts Are Just Shell Commands With Names
// package.json
{
"name": "npm-watch-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
}
}
// server.js
import { createServer } from "node:http";
const GREETING = "Hello from v1";
const server = createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end(GREETING);
});
server.listen(3000, () => console.log(`listening on http://localhost:3000 — ${GREETING}`));
$ npm run start
> npm-watch-demo@1.0.0 start
> node server.js
listening on http://localhost:3000 — Hello from v1
npm run start (or just npm start, for that one specific name) looked up the start entry and ran node server.js — no different from typing the command directly, except now it’s documented in one place and the same for every machine that runs it.
node —watch Restarts on Every Save
dev runs the same server through --watch, a flag built into node itself.
$ npm run dev
> npm-watch-demo@1.0.0 dev
> node --watch server.js
listening on http://localhost:3000 — Hello from v1
Editing GREETING in server.js from "Hello from v1" to "Hello from v2" while that process is still running, and saving the file, produces this — with no restart of the npm command itself:
Change detected in '/root/node-api/server.js'
Restarting 'server.js'
listening on http://localhost:3000 — Hello from v2
--watch watches the files Node has actually loaded (here, just server.js) and re-runs the whole process on a change, printing which file triggered it before the server comes back up with the new code already in effect.
Installing a Dependency and Reading What Changed
$ npm install express
added 65 packages, and audited 66 packages in 3s
29 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
// package.json (after install)
{
"name": "npm-watch-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^5.3.0"
}
}
The install added one line under dependencies, not 5.3.0 but ^5.3.0. The caret is a semver range: it allows npm to install any 5.x.y release later, but never 6.0.0, under the assumption that a semver-respecting package only makes breaking changes on a major version bump. npm install also wrote (and would commit alongside package.json) a package-lock.json pinning the exact resolved version and every transitive dependency underneath it — that file, not the caret range, is what makes a second npm install on another machine reproducible.
Practice
1. Add a test script that runs node --test, then run npm test in a directory with no test files yet and read what Node prints.
Solution
{
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"test": "node --test"
}
}
$ npm test
> npm-watch-demo@1.0.0 test
> node --test
ℹ tests 0
ℹ suites 0
ℹ pass 0
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 19.535675
node --test runs successfully with zero tests found rather than erroring — it only fails the process if a test it does find fails. (duration_ms varies run to run; the rest of the summary doesn’t.)
2. Change the dev script to pass --watch-path=. explicitly instead of relying on the default, and confirm npm run dev still starts the same way.
Solution
{
"scripts": {
"dev": "node --watch-path=. server.js"
}
}
$ npm run dev
> npm-watch-demo@1.0.0 dev
> node --watch-path=. server.js
listening on http://localhost:3000 — Hello from v2
--watch-path doesn’t widen what’s watched on top of the default — it replaces it. Without --watch-path, Node watches the files it actually imported; with it, Node watches only the path(s) given, even if an imported file lives outside them. That matters when a change to a file that’s read but never imported (a config file, a template) should also trigger a restart: naming that file’s directory with --watch-path is how to add it, but doing so means also naming every directory holding an imported module, or those changes stop triggering a restart at all.
3. Run npm install express --save-exact instead, and compare the dependencies entry it writes.
Solution
$ npm install express --save-exact
...
found 0 vulnerabilities
{
"dependencies": {
"express": "5.3.0"
}
}
No caret: --save-exact pins the dependency to the exact version installed right now, so a later npm install on a fresh clone installs precisely 5.3.0 even without reading the lock file.
4. Run npm ls express after installing it, and explain the tree it prints.
Solution
$ npm ls express
npm-watch-demo@1.0.0 /root/node-api
`-- express@5.3.0
npm ls reads the installed tree in node_modules (backed by package-lock.json), not just the range in package.json — it shows the one concrete version that got resolved and installed.