🧰 Core Modules: fs, path & http
Every Node install comes with a toolbox already packed. Before you ever run npm install, you can read and write files, build cross-platform paths, and stand up a web server — all with built-in modules. This lesson tours the three you'll reach for daily, plus a handful of essential extras.
Week 7 · Day 1 (Monday: Node.js Fundamentals) · Lecture 3
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what core modules are and import them with
requireandimport - Read, write, append, and stream files with the modern
fs/promisesAPI - Perform directory operations and avoid blocking synchronous calls
- Build cross-platform paths safely with the
pathmodule - Create an HTTP server, route requests, and serve files
- Use
os,events, andutil.promisifywhere they fit
Estimated Time: 75 minutes
Practice: Build a note-saver that persists to disk, and a tiny static-file server using only core modules.
In This Lesson
What Are Core Modules?
Core modules are libraries built into the Node runtime itself. They need no installation, they're maintained alongside Node, and they're tuned for its event-driven model. You pull one in by name — no package required.
// CommonJS (the classic style) — the node: prefix marks a built-in
const fs = require('node:fs/promises');
const path = require('node:path');
const http = require('node:http');
// ES Modules (modern) — needs "type": "module" in package.json
import fs from 'node:fs/promises';
import path from 'node:path';
import http from 'node:http';
🧰 The Toolbox Analogy
Core modules are the tools that come standard with every purchase. You don't visit the hardware store (npm) for a screwdriver — fs and http are already in the drawer. Some tools you'll grab daily; others (zlib, dgram) sit in the back for specialist jobs. npm just adds specialized power tools on top.
The fs Module
The file system module reads, writes, and manages files and directories. It offers three flavours of nearly every operation — and choosing the right one matters.
| Style | Import | When to use |
|---|---|---|
| Promise-based | require('node:fs/promises') | Default choice — clean async/await |
| Callback-based | require('node:fs') | Legacy code, streams, low-level control |
| Synchronous | fs.readFileSync() | Startup scripts & CLIs only — never in a server hot path |
Reading files
const fs = require('node:fs/promises');
// ✅ Modern promise API with async/await
async function readNotes() {
try {
const data = await fs.readFile('notes.txt', 'utf8');
console.log(data);
} catch (err) {
console.error('Could not read file:', err.message);
}
}
readNotes();
For very large files, don't load the whole thing into memory — stream it in chunks:
const { createReadStream } = require('node:fs');
const stream = createReadStream('huge.log', { encoding: 'utf8' });
stream.on('data', (chunk) => console.log(`got ${chunk.length} bytes`));
stream.on('end', () => console.log('done reading'));
stream.on('error', (err) => console.error(err));
Writing & appending
const fs = require('node:fs/promises');
async function saveLog(line) {
// writeFile replaces the whole file...
await fs.writeFile('output.txt', 'Hello, World!\n');
// ...appendFile adds to the end without erasing what's there
await fs.appendFile('app.log', `${new Date().toISOString()} — ${line}\n`);
console.log('saved');
}
saveLog('server started');
Directory operations
const fs = require('node:fs/promises');
async function setup() {
// recursive: true creates every missing parent, no error if it exists
await fs.mkdir('data/cache', { recursive: true });
// List directory contents
const entries = await fs.readdir('data');
console.log('contents:', entries);
// Inspect a single entry
const info = await fs.stat('data/cache');
console.log('is directory?', info.isDirectory());
}
setup();
⚠️ existsSync and the race
Avoid the "check then act" pattern (if (existsSync(p)) writeFile(p)) — the file can change between the two calls. Prefer just attempting the operation and handling the error, or use options like { recursive: true } and { flag: 'wx' } that make the intent atomic.
💡 Real-world: a tiny persistent counter
const fs = require('node:fs/promises');
async function bumpVisits() {
let count = 0;
try {
count = Number(await fs.readFile('visits.txt', 'utf8'));
} catch (err) {
if (err.code !== 'ENOENT') throw err; // ENOENT = file not found → start at 0
}
count += 1;
await fs.writeFile('visits.txt', String(count));
return count;
}
bumpVisits().then((n) => console.log(`Visit #${n}`));
Checking err.code === 'ENOENT' is the idiomatic way to say "it's fine if the file doesn't exist yet."
The path Module
Never build file paths by gluing strings with /. Windows uses \, Unix uses /, and hand-joining invites bugs and security holes. The path module handles all of it correctly.
const path = require('node:path');
// Join segments using the OS-correct separator
path.join(__dirname, 'public', 'css', 'app.css');
// → /home/ray/project/public/css/app.css
// Resolve to an absolute path from the current working directory
path.resolve('docs', 'guide.md');
// → /home/ray/project/docs/guide.md
path.basename('/users/docs/report.pdf'); // 'report.pdf'
path.basename('/users/docs/report.pdf', '.pdf'); // 'report'
path.dirname('/users/docs/report.pdf'); // '/users/docs'
path.extname('script.js'); // '.js'
// Break a path into its parts
path.parse('/home/ray/docs/report.pdf');
// { root: '/', dir: '/home/ray/docs', base: 'report.pdf', ext: '.pdf', name: 'report' }
// Clean up '..' and '.' segments
path.normalize('/users/./docs/../docs/report.pdf'); // '/users/docs/report.pdf'
✅ Why path matters
- Cross-platform — the same code runs on Windows, macOS, and Linux
- Security — normalizing user input helps block directory-traversal attacks (
../../etc/passwd) - Reliability —
__dirname+path.joingives paths that don't break when you run the script from a different folder
In ES Modules there's no __dirname; recreate it with import.meta.dirname (Node 20.11+) or path.dirname(fileURLToPath(import.meta.url)).
The http Module
This is the module that made Node famous: with a few lines you have a working web server. Every framework you'll use — Express included — is built on top of it.
A server with routing
const http = require('node:http');
const server = http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET' && req.url === '/') {
res.statusCode = 200;
res.end(JSON.stringify({ message: 'Welcome!' }));
} else if (req.method === 'GET' && req.url === '/api/users') {
const users = [
{ id: 1, name: 'Ada Lovelace' },
{ id: 2, name: 'Grace Hopper' },
];
res.statusCode = 200;
res.end(JSON.stringify(users));
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Not Found' }));
}
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
});
Notice how quickly this if/else ladder gets tedious. Multiply it by dozens of routes and you'll understand exactly why Express exists — but it's healthy to see the raw machinery first.
Serving static files
const http = require('node:http');
const fs = require('node:fs/promises');
const path = require('node:path');
const MIME = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.json': 'application/json',
'.png': 'image/png',
};
const server = http.createServer(async (req, res) => {
// Block directory traversal by normalizing and stripping leading '..'
const safeUrl = path.normalize(req.url).replace(/^(\.\.[/\\])+/, '');
const filePath = path.join(__dirname, 'public', safeUrl === '/' ? 'index.html' : safeUrl);
try {
const content = await fs.readFile(filePath);
const type = MIME[path.extname(filePath)] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type });
res.end(content);
} catch (err) {
res.writeHead(err.code === 'ENOENT' ? 404 : 500);
res.end(err.code === 'ENOENT' ? 'Not Found' : 'Server Error');
}
});
server.listen(3000, () => console.log('http://localhost:3000/'));
Node as a client
const https = require('node:https');
https.get('https://api.github.com/users/nodejs', {
headers: { 'User-Agent': 'node-course' }
}, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => console.log(JSON.parse(data).name));
}).on('error', (err) => console.error(err.message));
💡 There's a modern shortcut
For making requests, current Node versions include the browser-standard fetch() globally — no import needed:
const res = await fetch('https://api.github.com/users/nodejs', {
headers: { 'User-Agent': 'node-course' }
});
const data = await res.json();
console.log(data.name);
Use fetch for clients; use the http module when you need a server or fine-grained stream control.
os, events & util
os — system information
const os = require('node:os');
console.log(`Platform: ${os.platform()}`); // 'linux' | 'darwin' | 'win32'
console.log(`CPU cores: ${os.cpus().length}`);
console.log(`Free memory: ${(os.freemem() / 1e9).toFixed(2)} GB`);
console.log(`Home dir: ${os.homedir()}`);
events — the EventEmitter
The event-driven heart of Node. Many built-ins (servers, streams) are emitters; you can build your own too.
const EventEmitter = require('node:events');
class Logger extends EventEmitter {
log(message) {
console.log(`LOG: ${message}`);
this.emit('logged', { message, at: Date.now() });
}
}
const logger = new Logger();
logger.on('logged', (data) => console.log('listener fired:', data.message));
logger.log('Hello, events!');
util — handy helpers
const util = require('node:util');
const fs = require('node:fs');
// Turn an old callback-style function into a promise-returning one
const readFileAsync = util.promisify(fs.readFile);
const data = await readFileAsync('config.json', 'utf8');
console.log(JSON.parse(data));
✅ Prefer the promise API directly
For fs you rarely need util.promisify anymore — just require('node:fs/promises'). But promisify is invaluable for wrapping other older callback-based libraries.
Practice & Quiz
🏋️ Exercise 1: A note saver
Goal: Write note.js that appends a note (passed on the command line) to notes.txt, each with a timestamp, then prints the whole file back.
// note.js — run as: node note.js "Buy milk"
// TODO: append the arg with a timestamp, then read & print notes.txt
💡 Hint
Read the note from process.argv[2], use fs.appendFile with a new Date().toISOString() prefix, then fs.readFile to print everything.
✅ Solution
// note.js
const fs = require('node:fs/promises');
async function main() {
const note = process.argv[2];
if (!note) {
console.error('Usage: node note.js "your note"');
process.exit(1);
}
await fs.appendFile('notes.txt', `${new Date().toISOString()} — ${note}\n`);
const all = await fs.readFile('notes.txt', 'utf8');
console.log('--- notes.txt ---');
console.log(all);
}
main();
🏋️ Exercise 2: JSON API endpoint
Goal: Build a server where GET /api/time returns the current server time as JSON, and everything else returns a 404 JSON error.
✅ Solution
const http = require('node:http');
http.createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.method === 'GET' && req.url === '/api/time') {
res.statusCode = 200;
res.end(JSON.stringify({ now: new Date().toISOString() }));
} else {
res.statusCode = 404;
res.end(JSON.stringify({ error: 'Not Found' }));
}
}).listen(3000, () => console.log('http://localhost:3000/api/time'));
🎯 Quick Quiz
Question 1: Which is the recommended modern way to read a file with async/await?
Question 2: Why use path.join() instead of string concatenation?
Question 3: What does an error with err.code === 'ENOENT' mean?
Best Practices & Pitfalls
✅ Do
- Default to
fs/promisesand async/await for readable async code - Build every path with
path.join/path.resolveand__dirname - Use the
node:prefix so it's obvious a module is built in - Stream large files instead of loading them entirely into memory
- Handle errors — check
err.codefor expected cases likeENOENT
❌ Don't
- Use
*Syncmethods inside a server request handler - Concatenate paths with
+ '/' + - Trust user-supplied paths without normalizing against traversal
- Reinvent a full framework by hand — this is where Express takes over
⚠️ Directory traversal is a real attack
// ❌ Dangerous — a request for /../../etc/passwd escapes your folder
const filePath = path.join(__dirname, 'public', req.url);
// ✅ Normalize and strip leading '..' segments first
const safe = path.normalize(req.url).replace(/^(\.\.[/\\])+/, '');
const filePath = path.join(__dirname, 'public', safe);
Always treat the URL as untrusted input before turning it into a file path.
Summary
🎉 Key Takeaways
- Core modules ship with Node — import them by name, no install needed
- fs reads/writes files; prefer
fs/promisesand stream large files - path builds safe, cross-platform paths — never concatenate strings
- http powers servers and clients; every framework is built on it
- os, events, and util round out the essential toolkit
📚 Additional Resources
- Node.js — File System (fs) documentation
- Node.js — Path documentation
- Node.js — HTTP documentation
- Node.js — Events (EventEmitter) documentation
🚀 What's Next?
You've felt the friction of hand-rolling routes and MIME types with the raw http module. Next lesson we bring in Express — the framework that turns all that boilerplate into a few expressive lines and kicks off building real APIs.
🎉 Toolbox unlocked!
Files, paths, and servers — all with zero dependencies. From here, frameworks only make the good parts easier.