לחיצה על ״שמירה״ מתחילה בממשק, אבל המידע צריך להגיע למקום שיודע לבדוק ולשמור אותו. בדרך יש בקשה, כתובת, גוף נתונים ותשובה. כשבונים דוגמה קטנה בלי framework (תשתית קוד מוכנה לבניית אפליקציות), קל לראות מה כל חלק עושה.
נכין טופס ששומר כותרת של פוסט. השרת יחזיר את הפוסט שנוצר, והממשק יציג אישור. השמירה בדוגמה היא בזיכרון בלבד: המידע נעלם כשעוצרים את השרת. זה מאפשר להתרכז במסלול הבקשה לפני שמוסיפים מסד נתונים.
מה צריך?
- Node.js 22 ומעלה, זמין דרך הפקודה
node. - דפדפן מודרני.
- תיקייה חדשה עם שני קבצים:
server.mjsו־index.html.
אין צורך להתקין חבילות. הדוגמה משתמשת במודול HTTP המובנה של Node.js וב־Fetch של הדפדפן.
השרת: לקבל, לבדוק ולענות
שמרו את הקוד הבא ב־server.mjs:
import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
const html = readFileSync(new URL("./index.html", import.meta.url));
const posts = [];
const maxBodyBytes = 16 * 1024;
function sendJson(res, status, data) {
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8"
});
res.end(JSON.stringify(data));
}
const server = createServer((req, res) => {
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8"
});
res.end(html);
return;
}
if (req.method === "GET" && req.url === "/api/posts") {
sendJson(res, 200, { posts });
return;
}
if (req.method !== "POST" || req.url !== "/api/posts") {
sendJson(res, 404, {
error: { code: "NOT_FOUND", message: "הכתובת לא נמצאה" }
});
return;
}
const contentType = String(req.headers["content-type"] ?? "")
.split(";")[0].trim().toLowerCase();
if (contentType !== "application/json") {
req.resume();
sendJson(res, 415, {
error: { code: "JSON_REQUIRED", message: "נדרש גוף JSON" }
});
return;
}
const chunks = [];
let size = 0;
let tooLarge = false;
req.on("data", chunk => {
if (tooLarge) return;
size += chunk.length;
if (size > maxBodyBytes) {
tooLarge = true;
chunks.length = 0;
sendJson(res, 413, {
error: { code: "BODY_TOO_LARGE", message: "הבקשה גדולה מדי" }
});
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (tooLarge) return;
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
sendJson(res, 400, {
error: { code: "INVALID_JSON", message: "ה־JSON אינו תקין" }
});
return;
}
const title = typeof body?.title === "string"
? body.title.trim() : "";
if (!title || title.length > 120) {
sendJson(res, 400, {
error: {
code: "INVALID_TITLE",
message: "הכותרת צריכה להכיל 1-120 תווים"
}
});
return;
}
const post = { id: randomUUID(), title };
posts.push(post);
sendJson(res, 201, { post });
});
req.on("error", () => {
if (!res.headersSent) {
sendJson(res, 400, {
error: { code: "READ_FAILED", message: "קריאת הבקשה נכשלה" }
});
}
});
});
server.listen(3000, "127.0.0.1", () => {
console.log("Open http://127.0.0.1:3000");
});
השרת מכיר שלוש בקשות: הצגת הדף, קבלת רשימת הפוסטים ויצירת פוסט. ביצירה הוא בודק את סוג התוכן, מגביל את גודל הגוף, מפענח JSON ובודק את הכותרת. רק אחרי הבדיקות הוא מוסיף רשומה למערך.
קוד הסטטוס 201 מציין שנוצר משאב חדש. קוד 400 מציין בעיה בבקשת הלקוח. כאן משתמשים בו עבור JSON לא תקין או כותרת שאינה עומדת בכללים. אלו המשמעויות המתוארות בתיעוד MDN ל־201 ול־400.
הממשק: לשלוח ולהציג תוצאה
שמרו את הקוד הבא ב־index.html:
<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>שמירת פוסט</title>
</head>
<body>
<h1>פוסט חדש</h1>
<form id="post-form">
<label for="title">כותרת</label>
<input id="title" name="title" required maxlength="120">
<button id="save" type="submit">שמירה</button>
</form>
<p id="message" role="status"></p>
<script>
const form = document.querySelector("#post-form");
const input = document.querySelector("#title");
const button = document.querySelector("#save");
const message = document.querySelector("#message");
form.addEventListener("submit", async event => {
event.preventDefault();
button.disabled = true;
message.textContent = "שומר...";
try {
const response = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: input.value })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message ?? "השמירה נכשלה");
}
message.textContent = `נשמר: ${data.post.title}`;
form.reset();
} catch (error) {
message.textContent = error.message;
} finally {
button.disabled = false;
}
});
</script>
</body>
</html>
הכותרת נשלחת כמחרוזת בתוך JSON. התוצאה מוצגת באמצעות textContent, כך שהכותרת נכתבת כטקסט ולא מתפרשת כ־HTML. השדה מתנקה רק אחרי הצלחה. כישלון משאיר את מה שהמשתמש כתב, כדי שיוכל לתקן או לנסות שוב.
הקוד מניח שהתשובות מהשרת הזה הן JSON, כפי שמוגדר בדוגמה. במערכת רחבה יותר גם שרת ביניים יכול להחזיר HTML או גוף ריק. המאמר על טיפול בשגיאות Fetch מטפל גם במקרים האלה.
מריצים ובודקים
מתוך התיקייה עם שני הקבצים, הריצו:
node server.mjs
פתחו בדפדפן את http://127.0.0.1:3000. אל תפתחו את קובץ ה־HTML ישירות: השרת מגיש את הדף ואת ה־API מאותו מקור, ולכן הכתובת היחסית /api/posts מגיעה אליו בלי צורך בהגדרות CORS (הכללים שמתירים לדף לפנות לשרת ממקור אחר).
הקלידו כותרת ולחצו על ״שמירה״. אמורה להופיע הודעה עם הכותרת שנשמרה. פתיחת http://127.0.0.1:3000/api/posts מציגה את הרשומות שהשרת מחזיק בזיכרון.
כעת פתחו את כלי הפיתוח של הדפדפן ובחרו בלשונית Network. שמרו פוסט נוסף וחפשו את הבקשה ל־/api/posts. אפשר לראות את שיטת POST, את הנתונים שנשלחו ואת תגובת 201 עם המזהה שנוצר.
למה בודקים גם בדפדפן וגם בשרת?
המאפיינים required ו־maxlength עוזרים למשתמש למלא את הטופס. הם אינם מחליפים בדיקה בשרת: לקוח אחר יכול לשלוח בקשה ישירות בלי להשתמש בטופס הזה.
אפשר לבדוק זאת מתוך ה־Console של הדף:
const response = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: " " })
});
console.log(response.status);
console.log(await response.json());
הסטטוס הוא 400, והתגובה מסבירה שהכותרת אינה תקינה. השרת מסיר את הרווחים עם trim, ולכן הם אינם נחשבים לכותרת.
השרת בדוגמה מאזין רק למחשב המקומי. הוא אינו כולל משתמשים, הרשאות או אחסון קבוע, ואינו מיועד להפעלה כאתר ציבורי. אחרי שמבינים את מסלול הבקשה, אפשר להוסיף מסד נתונים ולבדוק מה קורה כשהשמירה עצמה נכשלת.