Build a Real-Time Reaction Wall
Build a synchronized reaction wall with WebSockets, a JSON API, and OpenAPI.
Introduction
30 Second Summary
Audience reactions lose their meaning when every screen tells a different story. Software clients face the same uncertainty when there is no clear way to read the totals.
In this project, you will build a reaction wall that keeps Like, Love, and Celebrate counts synchronized across browser windows. A Node.js server will expose the same totals through a JSON endpoint described by an OpenAPI contract.
What You'll Build
Your finished demo shows synchronized reaction totals across two browser windows alongside matching JSON data documented by a machine-readable contract.
By the end of this project, you'll have:
- A live reaction wall where you click Like, Love, or Celebrate from either browser window.
- A synchronized multi-window demo where every connected view shows the same authoritative totals.
- A self-described JSON API with live counts at /api/reactions plus an OpenAPI 3.2.0 contract at /openapi.json.
- Secret Mission: Add /api/health to the server plus a matching reusable health schema in openapi.json.
Are there any prerequisites?
This project assumes you are comfortable with JavaScript plus Node.js. It also assumes npm, Visual Studio Code, Chrome, or Microsoft Edge are available.
Before We Start
Before the hands-on work begins, take a moment to lock in what you are building and why it matters. You will connect one shared reaction state to live browser updates, JSON endpoints, and a machine-readable API contract.
Set Up the Project and First Page
Every browser window in your finished Reaction Wall will depend on one Node.js server. That server needs a predictable foundation before it can hold shared state.
A pinned runtime keeps the project reproducible across machines. You'll prepare a fresh Visual Studio Code project with Node.js v24.20.0 LTS plus the ws library at version 8.21.3.
In this step, get ready to:
- Create the real-time-reaction-wall workspace in Visual Studio Code.
- Verify Node.js v24.20.0 LTS and install ws version 8.21.3.
- Run the local server and load the starter Reaction Wall.
Verify Node.js in the project folder
A project folder gives the server files one predictable home. Visual Studio Code's integrated terminal starts in that open folder.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
- Select File from the top menu.
- Select Open Folder....
- Select New Folder in the folder picker.
- Name the new Desktop folder real-time-reaction-wall.
- Select Select Folder on Windows or Open on macOS.
You'll see real-time-reaction-wall at the top of the Explorer sidebar. This confirms that Visual Studio Code is using the correct workspace.
- Select Terminal from the top menu.
- Select New Terminal.
- Check the active Node.js version by running this command:
node --version
What does this command do?
The --version flag prints the Node.js release used by this terminal. The result determines which path you take below.
✔️ I see the required Node.js release
Your terminal shows v24.20.0. Your runtime is ready for the pinned project.
ⓧ I see an older version
The installed runtime is older than the project's pinned release. Updating it now keeps later behavior consistent.
- Visit the official Node.js download page.
- Download the Node.js v24.20.0 LTS installer for your operating system.
- Run the downloaded installer.
- Keep the installer's default options.
- Close Visual Studio Code after installation finishes.
- Return to the real-time-reaction-wall folder in Visual Studio Code.
- Create a fresh integrated terminal from the Terminal menu.
- Repeat the version check from above. You should now see v24.20.0.
ⓧ Node.js is unavailable
This terminal cannot access Node.js yet. The official installer provides Node.js plus npm.
- Visit the official Node.js download page.
- Download the Node.js v24.20.0 LTS installer for your operating system.
- Run the downloaded installer.
- Keep the installer's default options.
- Close Visual Studio Code after installation finishes.
- Return to the real-time-reaction-wall folder in Visual Studio Code.
- Create a fresh integrated terminal from the Terminal menu.
- Repeat the version check from above. You should now see v24.20.0.
Still unable to verify Node.js?
Make sure you opened a fresh terminal after running the installer. An existing terminal can keep the previous environment settings.
If the version still does not appear, restart your computer before checking again.
Ask for help with the details from your terminal: Help me verify my Node.js installation.
Define the project manifest
The package.json file tells Node.js how to load this project. It also pins the server dependency to one exact release.
- Select the Explorer view in the left Activity Bar.
- Select the New Folder control beside real-time-reaction-wall.
- Name the folder public.
- Select the New File... control beside real-time-reaction-wall.
- Name the file package.json.
- Paste this project manifest into package.json:
{
"name": "real-time-reaction-wall",
"version": "1.0.0",
"private": true,
"description": "A real-time reaction wall with a JSON API and OpenAPI specification.",
"type": "module",
"main": "index.js",
"dependencies": {
"ws": "8.21.3"
}
}
What does this manifest define?
- The "type": "module" setting lets index.js use ECMAScript module imports.
- The dependencies object pins ws to version 8.21.3.
- The private setting protects this local project from accidental publication as a package.
- Save package.json.
- Install the declared dependency by running:
npm install
What does this command do?
npm reads package.json and installs its declared dependencies locally. This gives the server the pinned ws release.
The terminal returns to a prompt after the installation completes. That's the dependency layer ready for the real-time server.
Having trouble installing the dependency?
Confirm that the terminal path ends in real-time-reaction-wall. npm needs to run beside package.json.
Check that every property in package.json has the same spelling shown above. A missing comma can prevent npm from reading the file.
Share the non-sensitive terminal output when asking for help: Help me diagnose this npm install problem.
Serve the starter page
The browser needs an HTML document to display. This first page gives the local server a visible result to serve.
- Select the public folder in the Explorer sidebar.
- Select the New File... control.
- Name the file index.html.
- Paste this starter page into public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Reaction Wall</title>
</head>
<body>
<main>
<h1>Real-Time Reaction Wall</h1>
<p>Your local reaction wall is ready to build.</p>
</main>
</body>
</html>
What does this page contain?
- The document metadata gives the browser a title plus a responsive viewport.
- The main element holds the visible starter content.
- The heading identifies the Reaction Wall when the server loads the page.
- Save public/index.html.
- Confirm that index.html appears inside public in the Explorer tree.
Is the page file in the wrong place?
The Explorer tree should show public/index.html beneath real-time-reaction-wall. Drag the file into public if it appears beside that folder.
Ask for help if the Explorer structure looks different: Help me place index.html inside the public folder.
An HTTP server receives the browser request and returns the saved page. The server below listens on port 3000.
- Select real-time-reaction-wall at the top of the Explorer tree.
- Select the New File... control.
- Name the file index.js.
- Paste this server into index.js:
import http from 'node:http';
import { readFile } from 'node:fs/promises';
const PORT = 3000;
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET' || (pathname !== '/' && pathname !== '/index.html')) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
const content = await readFile('./public/index.html');
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
response.end(content);
});
server.listen(PORT, () => {
console.log(`Reaction Wall running at http://localhost:${PORT}`);
});
How does the starter server work?
- The imports provide Node.js's HTTP server plus its promise-based file reader.
- The PORT constant gives the local server port 3000.
- The route check accepts browser requests for / or /index.html.
- The readFile() call loads public/index.html from the project folder.
- The response sends the HTML content back with the matching content type.
- The server.listen() call starts the server and prints its local address.
- Save index.js.
Watch mode keeps the process active in this terminal. The standard Control+C shortcut stops it when you finish.
Before you run the server, what message do you expect it to print when port 3000 is ready?
- Start the local server in watch mode by running:
node --watch index.js
What does this command do?
Node.js runs index.js and watches the project for saved changes. The server stays active so your browser can request the page.
You'll see Reaction Wall running at http://localhost:3000 in the terminal. This confirms that the HTTP server is listening on port 3000.
- Switch to Chrome or Microsoft Edge.
- Enter http://localhost:3000 in the address bar.
- Press Enter to load the page.
You'll see the heading Real-Time Reaction Wall above the starter message. You now have a local page served by your own Node.js process.
Can't see the starter wall?
Keep the terminal running while the browser loads http://localhost:3000. Stopping the process also stops the local page.
Confirm that index.js sits beside package.json. Confirm that index.html sits inside public.
Use the terminal output when asking for help: Help me debug my local Reaction Wall server.
✔️ Awesome, I've got everything!
- Keep node --watch index.js running for the next step.
- Confirm that all three project files are saved.
ⓧ I'd like to double check the full code
These reference files show the exact project state for this checkpoint.
{
"name": "real-time-reaction-wall",
"version": "1.0.0",
"private": true,
"description": "A real-time reaction wall with a JSON API and OpenAPI specification.",
"type": "module",
"main": "index.js",
"dependencies": {
"ws": "8.21.3"
}
}
What should this file confirm?
This file defines the ESM project and pins its only dependency. Compare every property with your saved package.json.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Reaction Wall</title>
</head>
<body>
<main>
<h1>Real-Time Reaction Wall</h1>
<p>Your local reaction wall is ready to build.</p>
</main>
</body>
</html>
What should this page confirm?
This file contains the complete starter page. Its location must remain public/index.html so the server can read it.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
const PORT = 3000;
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET' || (pathname !== '/' && pathname !== '/index.html')) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
const content = await readFile('./public/index.html');
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
response.end(content);
});
server.listen(PORT, () => {
console.log(`Reaction Wall running at http://localhost:${PORT}`);
});
What should this server confirm?
This file is the complete server for this step. It serves the starter page through port 3000 and prints the local URL.
Your pinned project now serves its first page from a local Node.js process. Next, you'll turn this starter into a clickable reaction dashboard.
Build a Local Reaction Wall
Your Node.js server already delivers a starter page from the real-time-reaction-wall folder. Now you can turn that page into something people can interact with.
A clickable local wall gives you an immediate visual win. A second browser window then reveals that browser-only state stays isolated inside each window.
In this step, get ready to:
- Build three reaction controls with local count displays.
- Style the wall as a responsive reaction dashboard.
- Compare the local counts across two browser windows.
Build the reaction controls
Each button needs a stable reaction name that JavaScript can read. The data-reaction and data-count attributes connect each button to its displayed total.
- In public/index.html, find the existing <main> element.
- Replace that element with this reaction wall structure:
<main class="wall">
<p class="eyebrow">Audience pulse</p>
<h1>Real-Time Reaction Wall</h1>
<p class="intro">Open this page in two windows and compare their local counts.</p>
<p id="connection-status" class="status" role="status">Local-only mode</p>
<section class="reactions" aria-label="Reaction choices">
<button type="button" class="reaction-button like" data-reaction="like">
<span class="reaction-name">Like</span>
<span class="reaction-count" data-count="like">0</span>
</button>
<button type="button" class="reaction-button love" data-reaction="love">
<span class="reaction-name">Love</span>
<span class="reaction-count" data-count="love">0</span>
</button>
<button type="button" class="reaction-button celebrate" data-reaction="celebrate">
<span class="reaction-name">Celebrate</span>
<span class="reaction-count" data-count="celebrate">0</span>
</button>
</section>
<p class="footer-note">Counts currently live only in this browser window.</p>
</main>
What does this structure do?
- The three buttons store their reaction names in data-reaction attributes.
- Each data-count span starts at 0 and provides a target for local updates.
- The status text makes the current local-only behavior visible.
- Save public/index.html.
- Refresh http://localhost:3000 in your browser.
You should see Like, Love, and Celebrate controls with totals of 0. That is your first interactive layout taking shape.
Do the reaction controls look incomplete?
- Confirm that the replacement stays inside the existing <body> element.
- Check that every opening <button> and <span> has a matching closing tag.
- Ask for help with the page structure: Help me find the HTML mismatch in my reaction controls.
The server currently knows how to return only the HTML page. A route map lets the same server return the stylesheet and browser script from their own URLs.
- In index.js, find the block that begins with const server = http.createServer.
- Replace that server block with this static-file routing code:
const staticFiles = new Map([
['/', ['./public/index.html', 'text/html; charset=utf-8']],
['/index.html', ['./public/index.html', 'text/html; charset=utf-8']],
['/style.css', ['./public/style.css', 'text/css; charset=utf-8']],
['/app.js', ['./public/app.js', 'text/javascript; charset=utf-8']]
]);
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
const staticFile = staticFiles.get(pathname);
if (request.method !== 'GET' || !staticFile) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
const [filePath, contentType] = staticFile;
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
});
How does static routing work?
- The staticFiles map pairs each browser path with a local file and its content type.
- The server looks up the requested path before reading the matching file.
- Unknown paths receive the existing plain-text response with status 404.
- Save index.js.
- Wait for the running server to restart.
- Refresh http://localhost:3000 to confirm the three controls still load.
You should see the same unstyled reaction controls. This proves the new route map still serves the page correctly.
Did the page stop loading?
- Confirm that const PORT = 3000; remains above staticFiles.
- Confirm that the existing server.listen block remains below the replacement.
- Ask for help with the server edit: Help me debug the staticFiles route map in index.js.
Style the reaction dashboard
The reaction structure now has classes for the wall, status, buttons, and counts. CSS turns those hooks into a centered dashboard that adapts to narrower screens.
- Create public/style.css inside the public folder from the Visual Studio Code Explorer sidebar.
You should see style.css listed beneath public.
- Add the page foundation to public/style.css with this code:
:root {
color-scheme: dark;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, #312e81 0, transparent 38%),
radial-gradient(circle at bottom right, #164e63 0, transparent 34%),
#0f172a;
}
What does the foundation style?
- The root styles set the dark color palette and font stack for the page.
- The universal selector keeps element dimensions predictable.
- The body grid centers the wall over two radial color gradients.
- In public/index.html, add this line below the <title> element:
<link rel="stylesheet" href="/style.css">
Why add this link?
The stylesheet link asks the server for /style.css. The route map returns the CSS file with its matching content type.
- Save public/style.css.
- Save public/index.html.
- Refresh the browser to see the dark gradient background.
The reaction controls should now sit in the center of a dark page.
Still seeing a white background?
- Check that style.css is inside the public folder.
- Check that the stylesheet link uses /style.css.
- Ask for help with the stylesheet connection: Help me find why public/style.css is not loading.
- Add the wall and heading styles below the existing CSS:
.wall {
width: min(760px, 100%);
padding: clamp(24px, 5vw, 48px);
border: 1px solid #334155;
border-radius: 28px;
background: rgb(15 23 42 / 88%);
box-shadow: 0 24px 70px rgb(0 0 0 / 35%);
text-align: center;
}
.eyebrow {
margin: 0 0 8px;
color: #67e8f9;
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 7vw, 4rem);
line-height: 1;
}
.intro,
.footer-note {
color: #94a3b8;
}
How does the wall take shape?
- The .wall selector creates a bounded card with responsive padding.
- The eyebrow uses compact uppercase text to label the dashboard.
- The heading scales with the viewport while the supporting text stays muted.
- Save public/style.css.
- Refresh the browser to see the rounded wall card and larger heading.
You should see the reaction content inside a centered card with a cyan eyebrow.
Is the wall still bare text?
- Confirm that the HTML uses class="wall" on the <main> element.
- Confirm that the CSS selector begins with .wall.
- Ask for help with the card styles: Help me match the wall classes between my HTML and CSS.
- Add the status and reaction grid styles below the existing CSS:
.status {
display: inline-block;
margin: 18px 0 26px;
padding: 8px 14px;
border-radius: 999px;
background: #1e293b;
color: #f8fafc;
font-weight: 700;
}
.status.live {
background: #14532d;
color: #bbf7d0;
}
.reactions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
What changes in the layout?
- The status becomes a compact pill above the controls.
- The reaction section becomes a three-column grid with equal tracks.
- The unused .status.live style prepares the status for a later server connection.
- Save public/style.css.
- Refresh the browser to see the local status pill above three equal columns.
The page should now separate its status from a three-column reaction grid.
Are the controls still stacked?
- Confirm that the section uses class="reactions".
- Confirm that the CSS selector is plural as .reactions.
- Ask for help with the grid: Help me debug the reactions grid layout.
- Add the reaction button interaction styles below the existing CSS:
.reaction-button {
min-height: 150px;
border: 0;
border-radius: 20px;
padding: 18px;
color: #ffffff;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.reaction-button:hover:not(:disabled) {
transform: translateY(-4px);
filter: brightness(1.08);
}
.reaction-button:active:not(:disabled) {
transform: translateY(0);
}
.reaction-button:disabled {
cursor: wait;
filter: grayscale(0.7);
opacity: 0.55;
}
How do the buttons respond?
- Each button receives a large rounded click target.
- The hover state lifts an available button by four pixels.
- The disabled state provides a muted appearance for later connection handling.
- Save public/style.css.
- Refresh the browser.
- Move your pointer over a reaction button to see it lift.
Each reaction should now look like a large rounded button with a visible hover response.
Do the buttons stay flat?
- Confirm that every button includes class="reaction-button".
- Check that the hover selector includes :hover:not(:disabled).
- Ask for help with the button interaction: Help me debug the reaction button hover style.
- Add the button colors and count typography below the existing CSS:
.like {
background: #2563eb;
}
.love {
background: #db2777;
}
.celebrate {
background: #7c3aed;
}
.reaction-name,
.reaction-count {
display: block;
}
.reaction-name {
font-size: 1rem;
font-weight: 800;
}
.reaction-count {
margin-top: 12px;
font-size: 3rem;
font-weight: 900;
}
How are reactions distinguished?
- Like uses blue as its background color.
- Love uses pink while Celebrate uses purple.
- The large count sits beneath the smaller reaction name.
- Save public/style.css.
- Refresh the browser to see three colored controls with large zero totals.
You should see a blue Like button, a pink Love button, and a purple Celebrate button.
Are the button colors missing?
- Confirm that the buttons include the like, love, and celebrate classes.
- Confirm that each color selector begins with a period.
- Ask for help with the color classes: Help me match each reaction button to its CSS color class.
- Finish the stylesheet with the footer and narrow-screen rules:
.footer-note {
margin: 24px 0 0;
font-size: 0.9rem;
}
@media (max-width: 600px) {
.reactions {
grid-template-columns: 1fr;
}
.reaction-button {
min-height: 110px;
}
}
How does the layout adapt?
- The footer receives spacing beneath the reaction grid.
- Screens up to 600px wide switch the reaction grid to one column.
- The buttons become shorter on those narrow screens.
- Save public/style.css.
- Narrow the browser window until the buttons form one column.
The three buttons should stack vertically when the page becomes narrow.
Do the buttons remain in three columns?
- Confirm that the media query appears after the main reaction styles.
- Check that the media query closes with its final brace.
- Ask for help with the responsive layout: Help me debug the narrow-screen media query.
Add browser-local reactions
The page now looks interactive. A local JavaScript object can hold the totals while each click updates the matching count on the page.
- Create public/app.js inside the public folder from the Explorer sidebar.
You should see app.js listed beside index.html and style.css.
- Add the local reaction logic to public/app.js with this code:
const buttons = [...document.querySelectorAll('[data-reaction]')];
const counts = {
like: 0,
love: 0,
celebrate: 0
};
function renderCounts() {
buttons.forEach((button) => {
const reaction = button.dataset.reaction;
const count = button.querySelector('[data-count]');
count.textContent = counts[reaction];
});
}
buttons.forEach((button) => {
button.addEventListener('click', () => {
counts[button.dataset.reaction] += 1;
renderCounts();
});
});
What does the local logic do?
- The buttons array collects every element with a data-reaction attribute.
- The counts object stores one total for each reaction inside the current page.
- The renderCounts() function copies those totals into the matching count elements.
- Each click increments one local total before rendering the page again.
- In public/index.html, add this line below the stylesheet link:
<script src="/app.js" defer></script>
Why defer the script?
The script loads from /app.js while the browser continues reading the HTML. It runs after the reaction buttons have been parsed.
- Save public/app.js.
- Save public/index.html.
- Refresh http://localhost:3000.
- Click the Like button once.
You should see the Like total change from 0 to 1. Your local reaction wall now responds to clicks.
Does the count stay at zero?
- Confirm that the script element uses src="/app.js".
- Check that each button has a data-reaction value matching a key in counts.
- Ask for help with the click handler: Help me debug why my local reaction count stays at zero.
One responsive window proves the local logic works. Two fresh windows expose where that logic stores its state.
- Open a second browser window.
- Load http://localhost:3000 in the second window.
- Refresh the first window.
- Refresh the second window.
Both windows should show zero for every reaction.
Before you click, predict whether the Like total will change in one window or both windows.
- Click the Like button once in the first window.
The first window should show Like at 1. The second window should remain at 0.
Why do the windows disagree?
Each window creates its own counts object when app.js loads. A click changes only the object inside that window.
This mismatch is the intended result. The wall needs one authoritative state source before every window can agree.
✔️ Awesome, I've got everything!
Great work. Your styled wall responds locally while two windows clearly demonstrate isolated browser state.
- Confirm that all five project files are saved.
- Keep the local server available for the next step.
What have you proved?
The page structure, styling, static routes, and local click behavior all work together. The two-window mismatch identifies the exact state problem that remains.
ⓧ I'd like to double check the full code
- Compare each saved file with the complete references below.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
const PORT = 3000;
const staticFiles = new Map([
['/', ['./public/index.html', 'text/html; charset=utf-8']],
['/index.html', ['./public/index.html', 'text/html; charset=utf-8']],
['/style.css', ['./public/style.css', 'text/css; charset=utf-8']],
['/app.js', ['./public/app.js', 'text/javascript; charset=utf-8']]
]);
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
const staticFile = staticFiles.get(pathname);
if (request.method !== 'GET' || !staticFile) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
const [filePath, contentType] = staticFile;
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
});
server.listen(PORT, () => {
console.log(`Reaction Wall running at http://localhost:${PORT}`);
});
What should index.js contain?
This file serves the HTML, CSS, and JavaScript assets from one local server.
{
"name": "real-time-reaction-wall",
"version": "1.0.0",
"private": true,
"description": "A real-time reaction wall with a JSON API and OpenAPI specification.",
"type": "module",
"main": "index.js",
"dependencies": {
"ws": "8.21.3"
}
}
What should package.json contain?
This file keeps the existing ESM configuration and exact local dependency pin unchanged.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Reaction Wall</title>
<link rel="stylesheet" href="/style.css">
<script src="/app.js" defer></script>
</head>
<body>
<main class="wall">
<p class="eyebrow">Audience pulse</p>
<h1>Real-Time Reaction Wall</h1>
<p class="intro">Open this page in two windows and compare their local counts.</p>
<p id="connection-status" class="status" role="status">Local-only mode</p>
<section class="reactions" aria-label="Reaction choices">
<button type="button" class="reaction-button like" data-reaction="like">
<span class="reaction-name">Like</span>
<span class="reaction-count" data-count="like">0</span>
</button>
<button type="button" class="reaction-button love" data-reaction="love">
<span class="reaction-name">Love</span>
<span class="reaction-count" data-count="love">0</span>
</button>
<button type="button" class="reaction-button celebrate" data-reaction="celebrate">
<span class="reaction-name">Celebrate</span>
<span class="reaction-count" data-count="celebrate">0</span>
</button>
</section>
<p class="footer-note">Counts currently live only in this browser window.</p>
</main>
</body>
</html>
What should index.html contain?
This file links both assets and defines the three reaction controls with their data attributes.
:root {
color-scheme: dark;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #0f172a;
color: #e2e8f0;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, #312e81 0, transparent 38%),
radial-gradient(circle at bottom right, #164e63 0, transparent 34%),
#0f172a;
}
.wall {
width: min(760px, 100%);
padding: clamp(24px, 5vw, 48px);
border: 1px solid #334155;
border-radius: 28px;
background: rgb(15 23 42 / 88%);
box-shadow: 0 24px 70px rgb(0 0 0 / 35%);
text-align: center;
}
.eyebrow {
margin: 0 0 8px;
color: #67e8f9;
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(2rem, 7vw, 4rem);
line-height: 1;
}
.intro,
.footer-note {
color: #94a3b8;
}
.status {
display: inline-block;
margin: 18px 0 26px;
padding: 8px 14px;
border-radius: 999px;
background: #1e293b;
color: #f8fafc;
font-weight: 700;
}
.status.live {
background: #14532d;
color: #bbf7d0;
}
.reactions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.reaction-button {
min-height: 150px;
border: 0;
border-radius: 20px;
padding: 18px;
color: #ffffff;
cursor: pointer;
transition: transform 120ms ease, filter 120ms ease;
}
.reaction-button:hover:not(:disabled) {
transform: translateY(-4px);
filter: brightness(1.08);
}
.reaction-button:active:not(:disabled) {
transform: translateY(0);
}
.reaction-button:disabled {
cursor: wait;
filter: grayscale(0.7);
opacity: 0.55;
}
.like {
background: #2563eb;
}
.love {
background: #db2777;
}
.celebrate {
background: #7c3aed;
}
.reaction-name,
.reaction-count {
display: block;
}
.reaction-name {
font-size: 1rem;
font-weight: 800;
}
.reaction-count {
margin-top: 12px;
font-size: 3rem;
font-weight: 900;
}
.footer-note {
margin: 24px 0 0;
font-size: 0.9rem;
}
@media (max-width: 600px) {
.reactions {
grid-template-columns: 1fr;
}
.reaction-button {
min-height: 110px;
}
}
What should style.css contain?
This file provides the complete dashboard layout, interaction styles, colors, and responsive rules.
const buttons = [...document.querySelectorAll('[data-reaction]')];
const counts = {
like: 0,
love: 0,
celebrate: 0
};
function renderCounts() {
buttons.forEach((button) => {
const reaction = button.dataset.reaction;
const count = button.querySelector('[data-count]');
count.textContent = counts[reaction];
});
}
buttons.forEach((button) => {
button.addEventListener('click', () => {
counts[button.dataset.reaction] += 1;
renderCounts();
});
});
What should app.js contain?
This file stores and renders reaction totals inside each individual browser window.
You have proved that the local interface works while its separate browser states disagree. Next, you will move those totals into one shared WebSocket server so every open window stays synchronized.
Synchronize Every Window with WebSockets
Your local Reaction Wall proved that each browser window keeps its own count. Now you'll connect both windows through WebSockets so every reaction reaches one shared source.
The Node.js process will own the reaction totals. Each update broadcasts a complete snapshot to every connected browser.
In this step, get ready to:
- Move the reaction counts into the server.
- Connect each browser window to the server.
- Verify that every window displays synchronized totals.
Add the shared WebSocket server
The ws package attaches a WebSocket server to the existing HTTP server. A single reactionCounts object then becomes the authoritative state for every connected window.
- In index.js, replace the imports and PORT declaration at the top with this shared-state setup:
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';
const PORT = 3000;
const reactionCounts = {
like: 0,
love: 0,
celebrate: 0
};
What Does This Setup Do?
- The WebSocket import provides the connection states used during a broadcast.
- The WebSocketServer import creates the server that accepts browser connections.
- The reactionCounts object stores one total for each supported reaction.
- Save index.js.
- Refresh either browser window at http://localhost:3000.
The watch process restarts the server. You'll see the starter wall load from the same local address.
Did the Server Stop Restarting?
Check that the ws import matches the code exactly. A missing brace or quote prevents Node.js from loading the file.
If the terminal mentions the dependency, confirm that package.json still lists 8.21.3 for ws.
Help me check the new imports and shared reaction state.
Static file reads can fail independently from request routing. A reusable serveFile() helper keeps that file-handling path in one place.
- In index.js, add this helper below staticFiles and above the existing server declaration:
async function serveFile(response, filePath, contentType) {
try {
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
} catch (error) {
console.error(error);
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Unable to read the requested file.');
}
}
How Does File Serving Work?
- The helper reads the requested file before sending it with the correct content type.
- The try block handles successful file reads.
- The catch block returns a server error if a file cannot be read.
- Save index.js.
The terminal restarts the server without a syntax error. The helper is now ready for the request handler.
- Replace the existing server declaration in index.js with this request handler:
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET') {
response.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Method not allowed.');
return;
}
const staticFile = staticFiles.get(pathname);
if (!staticFile) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
const [filePath, contentType] = staticFile;
await serveFile(response, filePath, contentType);
});
What Changed in the Request Handler?
- The method check rejects requests that do not use GET.
- The static-file lookup returns a clear response when the requested path does not exist.
- A valid path passes its file name and content type to serveFile().
- Save index.js.
- Refresh the Reaction Wall in either browser window.
You'll see the page load with its existing dashboard styling. This confirms the refactored HTTP handler still serves the browser files.
Does the Page Show Not Found?
Confirm that const staticFile = staticFiles.get(pathname); appears before the missing-file check.
Check that the final request line calls serveFile() with filePath and contentType.
Help me debug the refactored request handler.
Every connected browser needs the same serialized snapshot. The server creates that JSON message once per update before broadcasting it.
- Add the WebSocket server and snapshot creator below the HTTP server declaration:
const wss = new WebSocketServer({ server });
function createCountsMessage() {
return JSON.stringify({
type: 'reaction-counts',
counts: reactionCounts
});
}
Why Send Complete Snapshots?
The wss instance shares the existing HTTP server. The createCountsMessage() function packages every reaction total into one message.
Complete snapshots give each browser the same authoritative state. A window never has to calculate totals from earlier updates.
- Save index.js.
The terminal restarts the server and prints the local Reaction Wall address again.
- Add the broadcast function below createCountsMessage() in index.js.
function broadcastCounts() {
const message = createCountsMessage();
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
How Does Broadcasting Work?
- The function creates one message from the current server state.
- The wss.clients collection contains every tracked browser connection.
- The ready-state check sends the snapshot only to open connections.
- Save index.js.
- Refresh either browser window to confirm the HTTP page still loads.
You'll see the Reaction Wall again. The server now has a broadcast path without disrupting static file requests.
Incoming messages need a strict boundary before they can change shared state. The connection handler ignores binary data and malformed messages.
- Add this connection handler below broadcastCounts() in index.js.
wss.on('connection', (socket) => {
socket.on('error', console.error);
socket.send(createCountsMessage());
socket.on('message', (data, isBinary) => {
if (isBinary) {
return;
}
try {
const message = JSON.parse(data.toString());
const isKnownReaction = Object.prototype.hasOwnProperty.call(
reactionCounts,
message.reaction
);
if (message.type !== 'reaction' || !isKnownReaction) {
return;
}
reactionCounts[message.reaction] += 1;
broadcastCounts();
} catch (error) {
console.error('Ignored invalid WebSocket message:', error.message);
}
});
});
How Are Reaction Messages Validated?
- A newly connected browser receives the current snapshot immediately.
- Binary messages leave the handler before they can affect the counts.
- The message must use the reaction type and name a property in reactionCounts.
- A valid reaction increments the shared total before broadcastCounts() updates every connection.
- Save index.js.
- Return to the terminal running the watch process from earlier.
You'll see Reaction Wall running at http://localhost:3000 after the restart. The shared WebSocket server is now listening beside the HTTP server.
Is the Server Failing After the Handler?
Check the closing braces around the nested message handler. The final two lines close the message handler before the connection handler.
Confirm that wss and broadcastCounts() are defined above the connection handler.
Help me debug the WebSocket server handler.
✔️ Awesome, I've got everything!
Your server now owns the reaction totals. Double check that index.js is saved.
ⓧ I'd like to double check the full code
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';
const PORT = 3000;
const reactionCounts = {
like: 0,
love: 0,
celebrate: 0
};
const staticFiles = new Map([
['/', ['./public/index.html', 'text/html; charset=utf-8']],
['/index.html', ['./public/index.html', 'text/html; charset=utf-8']],
['/style.css', ['./public/style.css', 'text/css; charset=utf-8']],
['/app.js', ['./public/app.js', 'text/javascript; charset=utf-8']]
]);
async function serveFile(response, filePath, contentType) {
try {
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
} catch (error) {
console.error(error);
response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Unable to read the requested file.');
}
}
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET') {
response.writeHead(405, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Method not allowed.');
return;
}
const staticFile = staticFiles.get(pathname);
if (!staticFile) {
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Not found.');
return;
}
const [filePath, contentType] = staticFile;
await serveFile(response, filePath, contentType);
});
const wss = new WebSocketServer({ server });
function createCountsMessage() {
return JSON.stringify({
type: 'reaction-counts',
counts: reactionCounts
});
}
function broadcastCounts() {
const message = createCountsMessage();
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
wss.on('connection', (socket) => {
socket.on('error', console.error);
socket.send(createCountsMessage());
socket.on('message', (data, isBinary) => {
if (isBinary) {
return;
}
try {
const message = JSON.parse(data.toString());
const isKnownReaction = Object.prototype.hasOwnProperty.call(
reactionCounts,
message.reaction
);
if (message.type !== 'reaction' || !isKnownReaction) {
return;
}
reactionCounts[message.reaction] += 1;
broadcastCounts();
} catch (error) {
console.error('Ignored invalid WebSocket message:', error.message);
}
});
});
server.listen(PORT, () => {
console.log(`Reaction Wall running at http://localhost:${PORT}`);
});
Connect the browser to the shared server
The browser uses its native WebSocket API to receive snapshots from the server. The buttons stay disabled whenever that connection is unavailable.
- Replace all contents of public/app.js with this UI-state foundation:
const buttons = [...document.querySelectorAll('[data-reaction]')];
const statusText = document.querySelector('#connection-status');
function setButtonsDisabled(disabled) {
buttons.forEach((button) => {
button.disabled = disabled;
});
}
function renderCounts(counts) {
buttons.forEach((button) => {
const reaction = button.dataset.reaction;
const count = button.querySelector('[data-count]');
count.textContent = counts[reaction] ?? 0;
});
}
setButtonsDisabled(true);
What Does the UI Foundation Do?
- The statusText reference lets the connection events update the visible status.
- The setButtonsDisabled() helper applies one connection state to every reaction button.
- The renderCounts() helper renders a complete server snapshot across all three totals.
- Save public/app.js.
- Refresh either browser window.
You'll see the reaction buttons become disabled. This guarded state prevents clicks before the browser has a live server connection.
Are the Buttons Still Active?
Check that setButtonsDisabled(true); appears after the two helper functions. Confirm that public/app.js is saved.
Help me check the browser UI-state code.
The page also needs to communicate its connection state clearly. The starter copy now shifts from local-only mode to a live shared-wall experience.
- In public/index.html, replace the eyebrow, introduction, and status paragraphs with this markup:
<p class="eyebrow">Live audience pulse</p>
<h1>Real-Time Reaction Wall</h1>
<p class="intro">
Open this page in two windows and send reactions from either one.
</p>
<p
id="connection-status"
class="status"
role="status"
aria-live="polite"
>
Connecting...
</p>
Why Update the Status Markup?
The status begins at Connecting... while the browser establishes its connection. The aria-live attribute allows status changes to be announced by assistive technology.
- Add the disabled attribute to the Like button's opening tag.
- Add the disabled attribute to the Love button's opening tag.
- Add the disabled attribute to the Celebrate button's opening tag.
- Replace the footer text with Counts come from one shared Node.js server.
- Save public/index.html.
- Refresh either browser window.
You'll see the updated live-wall copy with a Connecting... status. The three buttons remain disabled while the connection code is added.
✔️ The live-wall markup is ready
Your page now describes shared reactions. Double check that public/index.html is saved.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Real-Time Reaction Wall</title>
<link rel="stylesheet" href="/style.css">
<script src="/app.js" defer></script>
</head>
<body>
<main class="wall">
<p class="eyebrow">Live audience pulse</p>
<h1>Real-Time Reaction Wall</h1>
<p class="intro">
Open this page in two windows and send reactions from either one.
</p>
<p
id="connection-status"
class="status"
role="status"
aria-live="polite"
>
Connecting...
</p>
<section class="reactions" aria-label="Reaction choices">
<button type="button" class="reaction-button like" data-reaction="like" disabled>
<span class="reaction-name">Like</span>
<span class="reaction-count" data-count="like">0</span>
</button>
<button type="button" class="reaction-button love" data-reaction="love" disabled>
<span class="reaction-name">Love</span>
<span class="reaction-count" data-count="love">0</span>
</button>
<button type="button" class="reaction-button celebrate" data-reaction="celebrate" disabled>
<span class="reaction-name">Celebrate</span>
<span class="reaction-count" data-count="celebrate">0</span>
</button>
</section>
<p class="footer-note">Counts come from one shared Node.js server.</p>
</main>
</body>
</html>
Connection events translate the socket's state into visible feedback. An open connection enables reactions while errors or disconnections disable them.
- Append this connection lifecycle code below setButtonsDisabled(true); in public/app.js.
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.onopen = () => {
statusText.textContent = 'Live';
statusText.classList.add('live');
setButtonsDisabled(false);
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'reaction-counts') {
renderCounts(message.counts);
}
};
socket.onerror = () => {
statusText.textContent = 'Connection error';
statusText.classList.remove('live');
setButtonsDisabled(true);
};
socket.onclose = () => {
statusText.textContent = 'Disconnected';
statusText.classList.remove('live');
setButtonsDisabled(true);
};
How Does the Browser Track the Connection?
- The protocol calculation selects the matching WebSocket scheme for the current page.
- The open event changes the status to Live before enabling the buttons.
- The message event renders only reaction-counts snapshots.
- The error and close events restore the guarded button state.
- Save public/app.js.
- Refresh either browser window.
The status changes from Connecting... to Live. The buttons become active once the initial count snapshot arrives.
Is the Status Stuck on Connecting?
Return to the terminal and confirm the server is still running. A stopped server leaves the browser without a connection target.
Check that the socket URL uses window.location.host exactly. This keeps the WebSocket connection on the same host as the page.
Help me debug the browser WebSocket connection.
The final browser responsibility is sending reaction intent. The server still decides the resulting total before broadcasting the next snapshot.
- Append this click handler below the connection lifecycle code in public/app.js.
buttons.forEach((button) => {
button.addEventListener('click', () => {
if (socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'reaction',
reaction: button.dataset.reaction
}));
});
});
What Does a Button Click Send?
The ready-state check stops a click from using a closed connection. A valid click sends the message type with the button's data-reaction value.
The browser does not increment its own total. It waits for the next authoritative snapshot from the server.
- Save public/app.js.
✔️ The browser connection is ready
Your browser now receives shared snapshots and sends reaction messages. Double check that public/app.js is saved.
ⓧ I'd like to double check the full code
const buttons = [...document.querySelectorAll('[data-reaction]')];
const statusText = document.querySelector('#connection-status');
function setButtonsDisabled(disabled) {
buttons.forEach((button) => {
button.disabled = disabled;
});
}
function renderCounts(counts) {
buttons.forEach((button) => {
const reaction = button.dataset.reaction;
const count = button.querySelector('[data-count]');
count.textContent = counts[reaction] ?? 0;
});
}
setButtonsDisabled(true);
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}`);
socket.onopen = () => {
statusText.textContent = 'Live';
statusText.classList.add('live');
setButtonsDisabled(false);
};
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'reaction-counts') {
renderCounts(message.counts);
}
};
socket.onerror = () => {
statusText.textContent = 'Connection error';
statusText.classList.remove('live');
setButtonsDisabled(true);
};
socket.onclose = () => {
statusText.textContent = 'Disconnected';
statusText.classList.remove('live');
setButtonsDisabled(true);
};
buttons.forEach((button) => {
button.addEventListener('click', () => {
if (socket.readyState !== WebSocket.OPEN) {
return;
}
socket.send(JSON.stringify({
type: 'reaction',
reaction: button.dataset.reaction
}));
});
});
Verify synchronized totals
Before you test it, do you expect a reaction sent from one window to appear in both windows?
- Switch back to both browser windows from earlier.
- Refresh each browser window.
- Confirm that both windows show Live above the buttons.
- Click the Like button in the first window.
- Click the Love button in the second window.
- Compare the Like, Love, and Celebrate totals across both windows.
Both windows display the same totals after every click. That's the synchronization working: one server state now drives every connected Reaction Wall.
Do the Windows Show Different Totals?
Refresh both windows so each one reconnects to the current watch process. Confirm that both status labels show Live before clicking a reaction.
Check that the server calls broadcastCounts() after incrementing reactionCounts.
Help me find why my Reaction Wall windows disagree.
Your Reaction Wall now keeps every connected browser in sync through one authoritative server state. Next up, you'll expose those same live totals through a JSON endpoint for software clients.
Expose the Shared Counts as JSON
Your Reaction Wall now keeps every open browser window synchronized through WebSockets. Each click updates the Node.js server's shared reactionCounts object.
Other clients need an HTTP request that returns the same state as JSON. In this step, you'll expose GET /api/reactions for that request-response path.
In this step, get ready to:
- Create a reusable helper for JSON responses.
- Return the shared reaction totals from GET /api/reactions.
- Compare the JSON response with both live Reaction Wall windows.
Create a reusable JSON response helper
Every JSON response needs a status code. It also needs a content type plus a serialized body.
- Switch back to index.js in Visual Studio Code.
- Place the following function between the staticFiles map and serveFile():
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8'
});
response.end(JSON.stringify(payload, null, 2));
}
What does this helper do?
- The statusCode argument lets each route choose its HTTP status.
- The Content-Type header identifies the response as application/json; charset=utf-8.
- JSON.stringify() converts the payload into an indented JSON body.
- Save index.js.
- Return to the integrated terminal.
You'll see Reaction Wall running at http://localhost:3000 again. This confirms that Node.js accepted the helper.
Server Does Not Restart?
- Check that sendJson() sits after the closing bracket of the staticFiles map.
- Compare the braces in your helper with the code block above.
- Help me fix the JSON helper.
The helper becomes reusable when server errors pass through it too. File-reading failures can now return a consistent JSON error object.
- Find the existing serveFile() function in index.js.
- Replace the entire function with this version:
async function serveFile(response, filePath, contentType) {
try {
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
} catch (error) {
console.error(error);
sendJson(response, 500, { error: 'Unable to read the requested file.' });
}
}
How Does File Error Handling Change?
The successful file response stays unchanged. The catch block now passes its error object to sendJson().
File failures now use the same JSON response format as the API routes.
- Save index.js.
- Return to the integrated terminal.
You'll see the local address printed again. The running server confirms that serveFile() can call the new helper.
Seeing a Syntax Error?
- Confirm that the try block closes before the catch block begins.
- Check that the error payload uses matching braces around error.
- Help me repair serveFile().
Add the reaction-count endpoint
A route connects a request path to a response. This route returns the existing reactionCounts object that powers every WebSocket snapshot.
- Find the http.createServer() callback in index.js.
- Replace the entire callback with the following version:
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed.' });
return;
}
if (pathname === '/api/reactions') {
sendJson(response, 200, reactionCounts);
return;
}
const staticFile = staticFiles.get(pathname);
if (!staticFile) {
sendJson(response, 404, { error: 'Not found.' });
return;
}
const [filePath, contentType] = staticFile;
await serveFile(response, filePath, contentType);
});
How Does the Route Work?
- The pathname value identifies which server route the client requested.
- The /api/reactions branch passes the existing reactionCounts object to sendJson().
- The 405 response handles unsupported methods.
- The 404 response handles unknown paths.
- Save index.js.
- Return to the integrated terminal.
You'll see Reaction Wall running at http://localhost:3000 after the watch process restarts.
Server Stops After the Route Edit?
- Check that the /api/reactions branch appears before the staticFiles.get(pathname) lookup.
- Confirm that every route branch ends with return.
- Help me debug the request handler.
Before you check the endpoint, do you expect its totals to match both Reaction Wall windows?
- Refresh each Reaction Wall window.
- Click Like in one wall window.
- Open a new browser tab.
- Enter http://localhost:3000/api/reactions in the address bar.
- Press Enter.
You'll see JSON with like, love, and celebrate totals. Each number matches the totals displayed in both Reaction Wall windows.
That's a strong checkpoint. Both interfaces now report the same authoritative totals.
✔️ Awesome, I've got everything!
Great work. Save index.js once more. Confirm that the wall remains Live after the server restarts.
ⓧ I'd like to double check the full code
Your index.js file should now match this complete version.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';
const PORT = 3000;
const reactionCounts = {
like: 0,
love: 0,
celebrate: 0
};
const staticFiles = new Map([
['/', ['./public/index.html', 'text/html; charset=utf-8']],
['/index.html', ['./public/index.html', 'text/html; charset=utf-8']],
['/style.css', ['./public/style.css', 'text/css; charset=utf-8']],
['/app.js', ['./public/app.js', 'text/javascript; charset=utf-8']]
]);
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8'
});
response.end(JSON.stringify(payload, null, 2));
}
async function serveFile(response, filePath, contentType) {
try {
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
} catch (error) {
console.error(error);
sendJson(response, 500, { error: 'Unable to read the requested file.' });
}
}
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed.' });
return;
}
if (pathname === '/api/reactions') {
sendJson(response, 200, reactionCounts);
return;
}
const staticFile = staticFiles.get(pathname);
if (!staticFile) {
sendJson(response, 404, { error: 'Not found.' });
return;
}
const [filePath, contentType] = staticFile;
await serveFile(response, filePath, contentType);
});
const wss = new WebSocketServer({ server });
function createCountsMessage() {
return JSON.stringify({
type: 'reaction-counts',
counts: reactionCounts
});
}
function broadcastCounts() {
const message = createCountsMessage();
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
wss.on('connection', (socket) => {
socket.on('error', console.error);
socket.send(createCountsMessage());
socket.on('message', (data, isBinary) => {
if (isBinary) {
return;
}
try {
const message = JSON.parse(data.toString());
const isKnownReaction = Object.prototype.hasOwnProperty.call(
reactionCounts,
message.reaction
);
if (message.type !== 'reaction' || !isKnownReaction) {
return;
}
reactionCounts[message.reaction] += 1;
broadcastCounts();
} catch (error) {
console.error('Ignored invalid WebSocket message:', error.message);
}
});
});
server.listen(PORT, () => {
console.log(`Reaction Wall running at http://localhost:${PORT}`);
});
Your shared counts are now available to browsers plus software clients. Next, you'll describe that route with an OpenAPI 3.2.0 document.
Publish the OpenAPI JSON Contract
Your live reaction wall already gives browsers a shared set of counts. Its JSON route lets software clients read those totals too.
However, another developer still has to inspect index.js to discover the response shape. An OpenAPI Specification turns that hidden behavior into a machine-readable contract.
In this step, get ready to:
- Create an OpenAPI document with a reusable response schema.
- Describe the successful GET /api/reactions response.
- Publish a contract whose property names match the live JSON response.
Create the contract and reusable schema
An OpenAPI document describes an HTTP API through structured JSON. A reusable schema defines the response shape once so each operation can reference the same rules.
Why write the contract by hand?
This API has one operation. A hand-written file keeps your attention on the contract itself.
A generator would add framework setup that this small server does not need. The direct approach makes every documented field visible.
- In Visual Studio Code, use the new-file button at the top of the Explorer sidebar to create openapi.json inside real-time-reaction-wall.
- Define the reusable reaction-count schema by pasting this code into openapi.json:
{
"components": {
"schemas": {
"ReactionCounts": {
"type": "object",
"additionalProperties": false,
"required": [
"like",
"love",
"celebrate"
],
"properties": {
"like": {
"type": "integer",
"minimum": 0
},
"love": {
"type": "integer",
"minimum": 0
},
"celebrate": {
"type": "integer",
"minimum": 0
}
}
}
}
}
}
What does this schema define?
- The ReactionCounts name gives the response shape a reusable location under components.schemas.
- The required array says every response contains all three reaction properties.
- The integer type keeps each total numeric.
- The minimum value prevents the contract from accepting negative totals.
- Save openapi.json.
- Confirm the Explorer sidebar lists openapi.json inside real-time-reaction-wall.
Seeing a JSON warning?
- Check that each opening brace has a matching closing brace.
- Check that every property except the final property in an object ends with a comma.
- Ask for help with the exact warning shown by Visual Studio Code: Help me find the JSON syntax problem in my ReactionCounts schema.
The reusable schema defines the data shape. The root metadata now needs to identify the contract itself.
- In openapi.json, place your cursor immediately below the opening brace.
- Add the OpenAPI version plus the API information by pasting this code above "components":
"openapi": "3.2.0",
"info": {
"title": "Reaction Wall API",
"version": "1.0.0",
"description": "Read the current reaction totals maintained by the real-time server."
},
What does this metadata communicate?
- The openapi field declares OpenAPI Specification version 3.2.0.
- The title field gives the API a human-readable name.
- The version field identifies this contract as 1.0.0.
- The description field explains that the API reads totals maintained by the real-time server.
- Save openapi.json.
- Confirm the first root property is openapi.
- Confirm the next root property is info.
Metadata showing as invalid JSON?
- Make sure the opening brace remains above the new metadata.
- Keep the comma after the closing brace for info because components follows it.
- Ask for help with the top of the file: Help me fix the metadata section in my openapi.json file.
Describe the reaction endpoint
A contract connects a URL path to an HTTP operation. The operation then describes each response that clients can expect.
- In openapi.json, locate the root-level "components" property.
- Define the reaction route by pasting this code immediately above "components":
"paths": {
"/api/reactions": {
"get": {
"summary": "Get current reaction totals",
"operationId": "getReactionCounts",
"responses": {
"200": {
"description": "Current totals for each supported reaction.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReactionCounts"
}
}
}
}
}
}
}
},
How does the operation use the schema?
- The /api/reactions path matches the existing server route.
- The get operation documents requests that read the current totals.
- The "200" response describes a successful request.
- The application/json content type matches the response produced by sendJson().
- The $ref value points to the reusable ReactionCounts schema.
- Save openapi.json.
- Confirm the root properties appear in this order: openapi, info, paths, components.
Is the path block underlined?
- Keep the comma after the closing brace for paths because components follows it.
- Check that $ref starts with #/components/schemas/ReactionCounts.
- Ask for help with the operation block: Help me fix the paths section in my OpenAPI JSON document.
✔️ Awesome, I've got everything!
Your openapi.json now contains metadata, one documented route, plus a reusable response schema.
ⓧ I'd like to double check the full code
Compare your complete openapi.json with this reference file.
{
"openapi": "3.2.0",
"info": {
"title": "Reaction Wall API",
"version": "1.0.0",
"description": "Read the current reaction totals maintained by the real-time server."
},
"paths": {
"/api/reactions": {
"get": {
"summary": "Get current reaction totals",
"operationId": "getReactionCounts",
"responses": {
"200": {
"description": "Current totals for each supported reaction.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReactionCounts"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"ReactionCounts": {
"type": "object",
"additionalProperties": false,
"required": [
"like",
"love",
"celebrate"
],
"properties": {
"like": {
"type": "integer",
"minimum": 0
},
"love": {
"type": "integer",
"minimum": 0
},
"celebrate": {
"type": "integer",
"minimum": 0
}
}
}
}
}
}
What should match?
The complete file keeps the operation under paths. Its response points to the schema under components.schemas.
Serve and verify the contract
The contract exists on disk. Your existing Node.js server now needs a route that reads the file and returns it to clients.
- Return to the index.js tab from earlier.
- Find the condition for pathname === '/api/reactions'.
- Serve the contract by pasting this block immediately below the reaction-route condition:
if (pathname === '/openapi.json') {
await serveFile(
response,
'./openapi.json',
'application/json; charset=utf-8'
);
return;
}
What does this route do?
- The path condition handles requests for /openapi.json.
- The existing serveFile() helper reads ./openapi.json from the project folder.
- The content type tells clients that the response contains JSON encoded with UTF-8.
- The return statement stops the request from falling through to static-file handling.
- Save index.js.
- Confirm the watch process restarts the server.
- Confirm the terminal prints Reaction Wall running at http://localhost:3000.
Server not restarting cleanly?
- Check that the new route sits inside the http.createServer() callback.
- Check that the route appears above const staticFile = staticFiles.get(pathname);.
- Ask for help with the new route: Help me debug the openapi.json route in index.js.
Before you load both documents, do you expect the contract to use the same property names as the live response?
- Switch back to the browser from earlier.
- Create a new browser tab.
- Enter http://localhost:3000/openapi.json in the address bar.
You'll see "openapi": "3.2.0" near the top. You'll also see a get operation under /api/reactions.
- Create another browser tab.
- Enter http://localhost:3000/api/reactions in the address bar.
- Compare the live response properties with the required properties in openapi.json.
You'll find like, love, plus celebrate in both documents. That proves the published contract describes the response your server actually returns.
You have closed the discovery gap. Another developer can now read your machine-readable contract without inspecting the server source.
Contract URL not loading?
- Confirm openapi.json sits beside index.js inside real-time-reaction-wall.
- Confirm the server route reads ./openapi.json with the leading dot and slash.
- Ask for help with the browser response: Help me find why my local OpenAPI JSON URL does not load.
✔️ The contract loads correctly
Your server now publishes the reaction counts through /api/reactions and its contract through /openapi.json.
ⓧ I'd like to double check index.js
Compare your complete index.js with this reference file.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import WebSocket, { WebSocketServer } from 'ws';
const PORT = 3000;
const reactionCounts = {
like: 0,
love: 0,
celebrate: 0
};
const staticFiles = new Map([
['/', ['./public/index.html', 'text/html; charset=utf-8']],
['/index.html', ['./public/index.html', 'text/html; charset=utf-8']],
['/style.css', ['./public/style.css', 'text/css; charset=utf-8']],
['/app.js', ['./public/app.js', 'text/javascript; charset=utf-8']]
]);
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8'
});
response.end(JSON.stringify(payload, null, 2));
}
async function serveFile(response, filePath, contentType) {
try {
const content = await readFile(filePath);
response.writeHead(200, { 'Content-Type': contentType });
response.end(content);
} catch (error) {
console.error(error);
sendJson(response, 500, { error: 'Unable to read the requested file.' });
}
}
const server = http.createServer(async (request, response) => {
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed.' });
return;
}
if (pathname === '/api/reactions') {
sendJson(response, 200, reactionCounts);
return;
}
if (pathname === '/openapi.json') {
await serveFile(
response,
'./openapi.json',
'application/json; charset=utf-8'
);
return;
}
const staticFile = staticFiles.get(pathname);
if (!staticFile) {
sendJson(response, 404, { error: 'Not found.' });
return;
}
const [filePath, contentType] = staticFile;
await serveFile(response, filePath, contentType);
});
const wss = new WebSocketServer({ server });
function createCountsMessage() {
return JSON.stringify({
type: 'reaction-counts',
counts: reactionCounts
});
}
function broadcastCounts() {
const message = createCountsMessage();
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
wss.on('connection', (socket) => {
socket.on('error', console.error);
socket.send(createCountsMessage());
socket.on('message', (data, isBinary) => {
if (isBinary) {
return;
}
try {
const message = JSON.parse(data.toString());
const isKnownReaction = Object.prototype.hasOwnProperty.call(
reactionCounts,
message.reaction
);
if (message.type !== 'reaction' || !isKnownReaction) {
return;
}
reactionCounts[message.reaction] += 1;
broadcastCounts();
} catch (error) {
console.error('Ignored invalid WebSocket message:', error.message);
}
});
});
server.listen(PORT, () => {
console.log(`Reaction Wall running at http://localhost:${PORT}`);
});
Where should the contract route sit?
The /openapi.json condition sits after the reaction endpoint. It appears before the static-file lookup so the server handles the contract directly.
Secret mission
Add and Document a Health Endpoint
Add a health endpoint that reports whether the Reaction Wall server is running. Then extend the OpenAPI contract so the implementation and documentation describe the same response.
Clean Up Your Resources
Clean Up Your Resources
Your Reaction Wall runs entirely on your computer, so it has no ongoing cloud costs or paid service charges. Choose the cleanup option that matches what you want to do next.
Resources you used:
- Running local server: The Node.js watch process in the Visual Studio Code terminal.
- Local project files: The real-time-reaction-wall directory containing index.js, openapi.json, package.json, and the public directory.
Keep everything running
No action needed. Choose this if you want to continue testing the Reaction Wall or extending its API contract.
- Leave the Node.js watch process running while you continue testing the Reaction Wall.
- Keep the real-time-reaction-wall directory with its local project files.
- Continue using the open browser windows to test synchronized reaction counts.
Pause - I'll come back to this later
Shut down the local server while keeping every project file ready for your return.
- Switch back to the Visual Studio Code terminal from earlier.
- Press Ctrl+C to stop the Node.js watch process.
- Check either open Reaction Wall window. You should see Disconnected as its connection status.
- Keep the real-time-reaction-wall directory on your computer.
That's your pause complete. The server is off while every project file remains ready.
Delete - I don't want to use this again
Remove all project resources to start fresh. Deleting the directory is permanent, so pause here if you may want the code later.
- Switch back to the Visual Studio Code terminal from earlier.
- Press Ctrl+C to stop the Node.js watch process.
macOS
- Move to the containing directory and remove the entire project by running these commands:
cd ..
rm -rf real-time-reaction-wall
ls
What does this cleanup do?
- The first command moves the terminal to the directory that contains real-time-reaction-wall.
- The removal command deletes the project directory with all its local contents.
- The final command lists the remaining files. You should not see real-time-reaction-wall in that list.
Still see the project directory?
- Confirm that the terminal no longer shows output from the running server.
- Check that the terminal is in the directory that contained real-time-reaction-wall.
- Help me remove the local Reaction Wall directory safely.
Windows
- Move to the containing directory and remove the entire project by running these commands in PowerShell:
cd ..
rmdir real-time-reaction-wall -Recurse -Force
dir
What does this cleanup do?
- The first command moves the terminal to the directory that contains real-time-reaction-wall.
- The removal command deletes the project directory with all its local contents.
- The final command lists the remaining files. You should not see real-time-reaction-wall in that list.
Still see the project directory?
- Confirm that the terminal no longer shows output from the running server.
- Check that the terminal is in the directory that contained real-time-reaction-wall.
- Help me remove the local Reaction Wall directory safely.
Cleanup complete. The local Reaction Wall and its installed dependency are gone.
Nice Work!
Nice Work!
You did it! Your real-time Reaction Wall now keeps every connected browser window synchronized. Its shared server state is also available through a documented JSON API.
You've learned how to:
- Synchronize a responsive reaction wall across multiple browser windows with one authoritative Node.js WebSocket server.
- Expose the wall's shared reaction totals through GET /api/reactions as JSON.
- Publish an OpenAPI Specification 3.2.0 contract that documents GET /api/reactions with a reusable schema.
- Secret Mission: Extend the server with GET /api/health while keeping openapi.json aligned with the {"status":"ok"} response.
Ready to quiz yourself?