Escena Viva already returns impeccable JSON, but nobody buys tickets by reading JSON. What is missing is the part the user sees: some HTML, a stylesheet and a bit of browser JavaScript that consumes the API we have just built.
Serving files from disk over HTTP looks like the simplest task in the module, and it is exactly the opposite: it is where the interesting problems pile up. One of them is a textbook security hole that empties your entire project with a single request, and which we are going to exploit and close in section 2. The rest have to do with doing it properly: sending with constant memory instead of loading the file into RAM, declaring the right type, and making sure the second visit downloads nothing.
All of this reuses Module 3 while barely adding concepts: resolveWithin from lesson 03-03, createReadStream and pipeline from 03-04 and 03-05, stat from 03-02 and zlib.createGzip from 03-05. This time, wired to the network.
Contents
- Escena Viva's mini front-end
- From URL to disk path: the attack and the defense
- The
Content-Typeby extension - Sending the file:
createReadStreamandpipeline stat,Content-Length, default index and404- Client-side caching:
Cache-Control,ETag,Last-Modifiedand the304 - Range requests:
Rangeand206 - Compression negotiated with
Accept-Encoding - In production this gets delegated
- Escena Viva's mini front-end
Create the public/ folder at the root of the project with three files. The HTML is deliberately minimal:
<!-- public/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Escena Viva - Listings</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<h1>Listings</h1>
<ul id="listings">Loading...</ul>
<script src="/app.js"></script>
</body>
</html>And the browser JavaScript consumes the API from the previous lesson:
// public/app.js — runs in the BROWSER: here we do have fetch and document.
fetch('/events')
.then((response) => response.json())
.then(({ events }) => {
document.querySelector('#listings').innerHTML = events
.map((e) => `<li>${e.title} - ${e.venue} (${e.occupancy}% full)</li>`).join('');
})
.catch((error) => console.error('Could not load the listings', error));public/styles.css can be anything; what matters is that it exists and is big enough for the caching in section 6 to be noticeable. The folder split is intentional: public/ holds what anyone may read; data/events.json and src/, never.
- From URL to disk path: the attack and the defense
The idea is obvious: the URL path is glued onto the public directory. And this is how the bug gets written:
// DANGER: do not run this on anything exposed
const PUBLIC_DIR = path.join(ROOT, 'public');
const filePath = path.join(PUBLIC_DIR, url.pathname); // <- the holeIt looks reasonable because path.join normalizes. The problem is that normalizing includes resolving the .., and the one who sets the pathname is the client: curl -s 'http://localhost:3000/../data/events.json'.
A browser collapses the .. before sending the request, so the attack does not come from the address bar: it comes from curl, from a script, or by encoding the sequence as %2e%2e%2f so it does not even look like what it is. path.join('/…/public', '/../data/events.json') returns /…/data/events.json, and your server hands over the whole catalog with total naturalness. With enough ../../, it hands over /etc/passwd. This is called path traversal and it is still near the top of web vulnerabilities decades after it was discovered.
We already wrote the defense in lesson 03-03: resolveWithin resolves the path and checks with path.relative that the result is still inside the base, throwing PATH_NOT_ALLOWED if it is not.
// The pathname arrives with a leading '/': strip it so it is relative to the base.
const relative = decodeURIComponent(url.pathname).replace(/^\/+/, '');
const filePath = resolveWithin(PUBLIC_DIR, relative); // throws if it escapesTwo details that make the defense complete:
- Decoding happens before resolving. Otherwise
%2e%2e%2fwould pass the filter without being../yet, and the file system would interpret it afterwards. Decode first and validate second is the correct order; the other way round is a classic vulnerability. PATH_NOT_ALLOWEDis already in the 04-02 table and maps to403. The central error handler translates it on its own: the attacker gets a clean403 Forbiddenand you get the attempt logged instderr.
Check it:
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/styles.css' # 200
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/../data/events.json' # 403
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost:3000/%2e%2e/data/events.json' # 403There is one case path does not cover, and we already warned about it in 03-03: a symbolic link inside public/ pointing outside produces a path that looks legitimate. If your public directory accepts files uploaded by third parties, validate with fs.realpath as well before serving.
- The
Content-Type by extension
Content-Type by extensionThe browser decides what to do with the response by its Content-Type, not by its extension or its content. If you serve styles.css as text/plain, the browser does not apply it; if you serve app.js as text/html, it does not run it; and in both cases there is no visible error, just an unstyled page that costs you half an afternoon.
// src/server/mime-types.js
// Table of extension -> MIME type. Deliberately short: what we serve and nothing else.
const path = require('node:path');
const MIME_TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.webp': 'image/webp',
'.woff2': 'font/woff2'
};
// By default, "uninterpreted bytes": the browser downloads it instead of
// trying to display it, which is the prudent thing.
const DEFAULT_TYPE = 'application/octet-stream';
function typeForFile(filePath) {
return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? DEFAULT_TYPE;
}
module.exports = { typeForFile, MIME_TYPES, DEFAULT_TYPE };Two rules that are not optional. The first: charset=utf-8 on anything textual. Without it, "Sala Bóveda" can end up as "Sala Bóveda", because the browser guesses the encoding and sometimes guesses wrong.
The second is about security. Browsers do sniffing: if the Content-Type looks dubious to them, they peek at the first bytes and decide for themselves. It sounds useful and it is dangerous: a file uploaded by a user and served as text/plain can be interpreted as HTML and run its <script> on your domain. The header that turns it off is one line:
With it, the browser obeys your Content-Type without arguing. It is one of the headers helmet sets for you in lesson 06-05; it pays to know what it does before delegating it.
- Sending the file:
createReadStream and pipeline
createReadStream and pipelineThe easy version, res.end(await fs.readFile(filePath)), works with styles.css and takes the server down with the 40 MB poster. readFile loads the whole file into memory before sending a single byte: with ten clients downloading at once that is 400 MB of RAM, and there is no backpressure. You have known the right way since lesson 03-05:
const { pipeline } = require('node:stream/promises');
const { createReadStream } = require('node:fs');
await pipeline(createReadStream(filePath), res);Three reasons why it should always be this way:
readFile + end |
createReadStream + pipeline |
|
|---|---|---|
| Memory | The size of the file, per client | One highWaterMark (64 KB), constant |
| Backpressure | None | Automatic: the stream pauses if res saturates |
| First byte | After reading the whole file | Almost immediate |
Backpressure is the point most people underestimate. res is a writable stream and pipeline respects its pace: if the client is on a slow connection, the disk read pauses by itself. And pipeline propagates errors and destroys both ends, which is exactly what plain pipe does not do: if the client closes the tab halfway through a download, pipe would leave the ReadStream open holding a file descriptor, and that is the classic leak that ends in EMFILE: too many open files after days of uptime.
An important nuance: if the client aborts, pipeline rejects with ERR_STREAM_PREMATURE_CLOSE. That is not a server failure and it must not reach the error handler as a 500:
try {
await pipeline(createReadStream(filePath), res);
} catch (error) {
// The client closed the connection: not our error, we just note it.
if (error.code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error;
console.error(`[static] download aborted by the client: ${filePath}`);
}
stat, Content-Length, default index and 404
stat, Content-Length, default index and 404Before opening the stream it is worth doing a stat, which gives us three things at once: whether it exists, the exact size in bytes —which goes straight into Content-Length, with nothing to compute, and beats letting Node use chunked because the browser can show real progress— and the modification date, which we will use in section 6.
It also solves two cases: the index file, because GET / asks for no particular file and we have to serve the directory's index.html; and the nonexistent file, which becomes a 404 by applying the discipline from 03-01: try and catch, never check first with exists —between the check and the open the file can disappear, and it is one extra system call on every request.
Putting it all together, src/server/static.js:
// src/server/static.js
// Serves files from public/ with a hardened path, MIME type and caching.
const fs = require('node:fs/promises');
const path = require('node:path');
const { createReadStream } = require('node:fs');
const { pipeline } = require('node:stream/promises');
const { ROOT } = require('../config/paths.js');
const { resolveWithin } = require('../utils/safe-path.js');
const { typeForFile } = require('./mime-types.js');
const PUBLIC_DIR = path.join(ROOT, 'public');
async function serveStatic(req, res, requestedPath) {
// 1. Decode and harden: throws PATH_NOT_ALLOWED (403) if it escapes.
const relative = decodeURIComponent(requestedPath).replace(/^\/+/, '');
let filePath = resolveWithin(PUBLIC_DIR, relative || 'index.html');
// 2. stat, with a default index for directories.
let stats;
try {
stats = await fs.stat(filePath);
if (stats.isDirectory()) {
filePath = path.join(filePath, 'index.html');
stats = await fs.stat(filePath);
}
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
const failure = new Error(`Resource ${requestedPath} does not exist`);
failure.appCode = 'RESOURCE_NOT_FOUND'; // -> 404 via http-errors.js
throw failure;
}
// 3. Headers (section 6 adds the caching ones here).
res.statusCode = 200;
res.setHeader('Content-Type', typeForFile(filePath));
res.setHeader('Content-Length', stats.size);
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.method === 'HEAD') return res.end(); // same headers, no body
await pipeline(createReadStream(filePath), res);
}
module.exports = { serveStatic, PUBLIC_DIR };It hooks into the router as a last resort: if no API route matches, we try to serve a static file before giving a 404.
- Client-side caching:
Cache-Control, ETag, Last-Modified and the 304
Cache-Control, ETag, Last-Modified and the 304Reloading the page downloads the CSS and the JS again even though not a single byte has changed. HTTP has two mechanisms to avoid that, and they complement each other.
Cache-Control: max-age=N says: "for N seconds, don't even ask me". The browser serves the file from its own disk with no request at all. It is as fast as it gets and also the most dangerous: if you publish a new version, whoever has the old one cached will not find out until it expires.
| Resource | Sensible value | Why |
|---|---|---|
index.html |
no-cache |
It is the front door: it must always be revalidated |
styles.css, app.js |
max-age=3600 |
They change rarely; one hour is a prudent compromise |
| Files with a hash in the name | max-age=31536000, immutable |
If the content changes so does the name: they expire by themselves |
| API responses | no-store |
Live data: capacity, available tickets |
Beware the naming trap: no-cache does not mean "don't cache", it means "cache but revalidate before using". The one that forbids storing is no-store.
Conditional validation is the second mechanism, and it is the one that produces the 304. It works like this: the server sends a tag with the response; the browser stores it and, next time, sends it back asking "is this still valid?". If it is, the server answers 304 Not Modified with no body.
There are two possible tags:
Last-Modified, a date. The client returns it inIf-Modified-Since. Its resolution is one second, so two changes within the same second go unnoticed.ETag, an opaque version identifier. The client returns it inIf-None-Match. It is the preferred mechanism: more precise and with no time zone ambiguity.
A strong ETag would be a hash of the content, but that forces you to read the whole file on every request, exactly what we wanted to avoid. The practical solution —the one almost every server uses— is a weak ETag derived from the stat: size and modification date.
// Weak ETag (W/ prefix): "same size and same date" is good enough as
// proof that the content has not changed, and it comes free with the stat.
function computeEtag(stats) {
return `W/"${stats.size.toString(16)}-${stats.mtimeMs.toString(16)}"`;
}
function isInClientCache(req, etag, stats) {
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch) return ifNoneMatch.split(',').some((value) => value.trim() === etag);
const ifModifiedSince = req.headers['if-modified-since'];
// The HTTP date has second resolution: the file's must be truncated.
if (ifModifiedSince) return Math.floor(stats.mtimeMs / 1000) * 1000 <= Date.parse(ifModifiedSince);
return false;
}And in serveStatic, right before writing the body:
const etag = computeEtag(stats);
res.setHeader('ETag', etag);
res.setHeader('Last-Modified', stats.mtime.toUTCString());
res.setHeader('Cache-Control', filePath.endsWith('index.html') ? 'no-cache' : 'max-age=3600');
if (isInClientCache(req, etag, stats)) {
res.removeHeader('Content-Length'); // a 304 goes with NO body and NO length
return sendNoContent(res, 304);
}The improvement is measurable in two commands:
curl -s -o /dev/null -w '%{http_code}: %{size_download} bytes\n' http://localhost:3000/styles.css
# 200: 1842 bytes
ETAG=$(curl -sI http://localhost:3000/styles.css | awk '/[Ee]Tag/{print $2}' | tr -d '\r')
curl -s -o /dev/null -H "If-None-Match: $ETAG" -w '%{http_code}: %{size_download} bytes\n' http://localhost:3000/styles.css
# 304: 0 bytesZero body bytes. On a page with twenty resources, the difference between the first visit and the second stops being a megabyte and becomes a handful of headers.
- Range requests:
Range and 206
Range and 206A client can ask for a chunk of a file instead of the whole thing. That is what a video player does when you jump to minute 12, and what a download manager does when resuming an interrupted download. For an event poster it is not essential, but it is worth knowing how it works.
The client asks with the Range: bytes=0-1023 header, and the server, if it accepts, responds 206 Partial Content with Content-Range and only those bytes:
res.setHeader('Accept-Ranges', 'bytes'); // announce that we know how to do it
const range = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
if (range) {
const start = range[1] === '' ? 0 : Number(range[1]);
const end = range[2] === '' ? stats.size - 1 : Math.min(Number(range[2]), stats.size - 1);
if (start > end) {
res.setHeader('Content-Range', `bytes */${stats.size}`);
return sendNoContent(res, 416); // Range Not Satisfiable
}
res.statusCode = 206;
res.setHeader('Content-Range', `bytes ${start}-${end}/${stats.size}`);
res.setHeader('Content-Length', end - start + 1);
// createReadStream's start and end are both INCLUSIVE: hence the -1 and the +1.
return pipeline(createReadStream(filePath, { start, end }), res);
}The two classic mistakes: forgetting that createReadStream's end is inclusive (hence the -1 and the +1), and returning 200 instead of 206, which makes the client believe you sent the whole file and reassemble it wrong.
- Compression negotiated with
Accept-Encoding
Accept-EncodingCSS and JS are text and compress enormously: a 100 KB CSS drops to 15-20 KB with gzip. But compressing is only valid if the client understands it, and that is negotiated: the client announces Accept-Encoding: gzip, deflate, br and the server chooses and declares it in Content-Encoding.
const COMPRESSIBLE = new Set(['.html', '.css', '.js', '.json', '.svg', '.txt']);
const acceptsGzip = (req.headers['accept-encoding'] ?? '').includes('gzip');
const worthIt = COMPRESSIBLE.has(path.extname(filePath)) && stats.size > 1024;
if (acceptsGzip && worthIt) {
res.setHeader('Content-Encoding', 'gzip');
res.setHeader('Vary', 'Accept-Encoding');
// The compressed size is not known in advance: drop Content-Length.
// Node switches to Transfer-Encoding: chunked automatically.
res.removeHeader('Content-Length');
return pipeline(createReadStream(filePath), createGzip(), res);
}Three points where almost everybody gets it wrong:
- Removing
Content-Length. If you leave it, you announce the uncompressed file size and the client will wait for bytes that never arrive, hanging until its timeout. - The
Vary: Accept-Encodingheader. Without it, an intermediate cache can store the compressed version and hand it to a client that does not accept gzip, which will see binary garbage. - Not compressing what is already compressed. A
.webp, a.pngor a.zipdo not shrink; you only burn CPU and, on small files, the response can even grow. Hence the 1 KB threshold and theCOMPRESSIBLElist.
Check it: curl -s -o /dev/null -w '%{size_download}\n' http://localhost:3000/styles.css returns 1842 bytes, and with -H 'Accept-Encoding: gzip' it drops to 612.
- In production this gets delegated
You have written a correct static file server: hardened, with MIME types, streams, conditional caching, ranges and compression. And in production, almost certainly, you will not use it. The reason is economic, not technical: a reverse proxy such as Nginx or a CDN serves static files with native code that has been optimized for twenty years, without occupying your event loop, and a CDN additionally places them physically near the user. Your Node process should be doing what only it can do.
| Aspect | Node serving statics | Reverse proxy or CDN |
|---|---|---|
| Cost per file | Occupies your API's event loop | Zero for your process |
| Latency | Your server's | That of the node nearest the user |
| TLS, HTTP/2, compression | Your responsibility | Included |
| When to use it | Development, testing, internal tools | Production with real traffic |
What has not been wasted time is understanding the mechanisms: when you configure expires in Nginx or a CDN's cache rules in lesson 11-04, you will be configuring exactly what you have just implemented by hand.
Common Mistakes and Tips
- Gluing
url.pathnameonto the base directory withpath.join. That is the path traversal vulnerability. AlwaysresolveWithin, and decoding before validating. - Serving with
readFile. It works until the day of the big file or the traffic spike.createReadStream+pipeline, no exceptions. - Using
pipeinstead ofpipeline. It neither propagates errors nor destroys the source: leaked file descriptors and, in the long run,EMFILE. - Forgetting
charset=utf-8on the text types. Broken accents that only show up in some browsers. - Leaving
Content-Lengthin place when compressing or when responding304. A hanging client in the first case, an invalid response in the second. - Compressing images, or serving the whole project directory. You burn CPU and save nothing; and
data/,src/and.envare not public. - Tip:
X-Content-Type-Options: nosnifffrom day one: one line that closes a whole family of attacks. And to debug caching,curl -IshowsETag,Last-ModifiedandCache-Controlwithout downloading the body.
Exercises
Exercise 1: demonstrating the attack and the defense
Write src/lab/test-traversal.js that, with the server running, requests these paths with fetch and prints a path | status | first 40 characters table: /index.html, /styles.css, /../data/events.json, /%2e%2e/data/events.json, /../../etc/hosts and /subdir/../styles.css. Explain why the last one must return 200.
Exercise 2: measuring the caching effect
Write a shell script that requests styles.css three times: with no conditional headers, with the ETag obtained from the first response, and with a made-up If-None-Match. Print the status and the downloaded bytes of each one and compute the percentage saved. Then modify the file (touch public/styles.css) and repeat: explain what changes and why touching the date is enough.
Exercise 3: complete Accept-Encoding
Extend the negotiation so it also supports br (Brotli, with zlib.createBrotliCompress), preferring it over gzip when the client accepts both. Check with three requests (gzip, br, no header) that the response's Content-Encoding is right in each case, and compare the three sizes.
Solutions
Solution 1. The expected result is 200 for /index.html, /styles.css and /subdir/../styles.css, and 403 for the other three.
const paths = ['/index.html', '/styles.css', '/../data/events.json',
'/%2e%2e/data/events.json', '/../../etc/hosts', '/subdir/../styles.css'];
for (const path of paths) {
// new URL is not used: it would normalize the '..' before sending them.
const response = await fetch(`http://localhost:3000${path}`);
const text = (await response.text()).slice(0, 40).replace(/\n/g, ' ');
console.log(`${path.padEnd(32)} | ${response.status} | ${text}`);
}/subdir/../styles.css returns 200 and that is correct: resolveWithin does not forbid .., it forbids leaving the base. Once resolved, that path points at public/styles.css, which is inside. Rejecting every string containing .. would be a blacklist defense, more fragile and with false positives: the good one validates the result, not the input.
Solution 2. The made-up ETag returns 200 with the full body: it does not match the current one, so the server assumes the client has an old version.
ETAG=$(curl -sI http://localhost:3000/styles.css | awk '/[Ee]Tag/{print $2}' | tr -d '\r')
curl -s -o /dev/null -w ' no header: %{http_code} %{size_download}\n' http://localhost:3000/styles.css
curl -s -o /dev/null -H "If-None-Match: $ETAG" -w ' correct etag: %{http_code} %{size_download}\n' http://localhost:3000/styles.css
curl -s -o /dev/null -H 'If-None-Match: W/"0-0"' -w 'made-up etag: %{http_code} %{size_download}\n' http://localhost:3000/styles.cssAfter the touch, the ETag changes even though the content is identical, because our tag is computed from mtimeMs and the size. That is the price of the weak ETag: it can invalidate too much (one unnecessary download) but never too little (serving stale content), and that split of risks is exactly the one we want.
Solution 3. The negotiation is resolved by looking at what the client accepts and choosing by our own preference:
const { createGzip, createBrotliCompress } = require('node:zlib');
function chooseCompressor(req) {
const accepts = req.headers['accept-encoding'] ?? '';
if (accepts.includes('br')) return { name: 'br', create: createBrotliCompress };
if (accepts.includes('gzip')) return { name: 'gzip', create: createGzip };
return null;
}Brotli compresses text somewhat better than gzip (on the order of 15-20% less on a typical CSS) in exchange for more CPU, which is why it is preferred for static files that also get cached. With no Accept-Encoding header, the file is served uncompressed with its original Content-Length.
Conclusion
Escena Viva now has a face. The same server that exposes the API serves public/index.html, public/styles.css and public/app.js, and along the way you have closed the most common hole of all: mapping the URL to disk with path.join lets GET /../../data/events.json walk off with your catalog, and the defense is resolveWithin from lesson 03-03, decoding before validating, with PATH_NOT_ALLOWED translating itself into 403 thanks to the 04-02 table. Rejecting the path by its result and not by whether it contains .. is what keeps /subdir/../styles.css working.
You know how to declare the type with your own MIME table —src/server/mime-types.js, with charset=utf-8 on everything textual and application/octet-stream by default— and why X-Content-Type-Options: nosniff is not optional. You send with createReadStream and pipeline, not with readFile: constant memory, automatic backpressure and destruction of both ends if the client aborts, with ERR_STREAM_PREMATURE_CLOSE treated as what it is —a disconnection, not a 500—. And stat gives you in one shot the existence check, the exact Content-Length and the date, plus the default index and the 404.
Caching is no longer a mystery: Cache-Control with its max-age, no-cache (revalidate) and no-store (don't store), and conditional validation with a weak ETag computed from size and mtime plus Last-Modified, answering 304 Not Modified with no body when If-None-Match or If-Modified-Since arrive —from 1842 bytes to 0—. You have seen Range/206 requests with their inclusive end, and compression negotiated through Accept-Encoding with zlib.createGzip, Content-Encoding, Vary and the mandatory removal of Content-Length. All of it knowing that in production this gets delegated to a reverse proxy or a CDN, and that what you learned is what you need to configure them.
So far, Escena Viva only hands things out: everything is GET requests. What is missing is what turns a website into a platform: receiving. In the next lesson, Receiving Data: Request Bodies and JSON, we will accumulate the Buffer chunks arriving over req —and you will see why concatenating into a string splits multibyte characters—, we will set a mandatory size limit with its 413, we will parse JSON and forms, and we will implement POST /orders, with its hand-written validation, its call into the domain and its 201 response with Location.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
