پیادهسازی سرور؛ مسیرها، بدنهٔ JSON و پاسخ خطا
در این درس ۶ بخش
در درس قبل مدل ساخته شد؛ اکنون آن را به HTTP وصل میکنیم. دو فایل این درس را با نام دقیق کنار model.mjs قرار دهید. نخست app.mjs یک سرور میسازد اما آن را روی پورت باز نمیکند؛ سپس server.mjs مسئول خواندن تنظیم و شروع اجراست. این جداسازی اجازه میدهد آزمونها سرور را روی پورت آزاد خودشان اجرا کنند. هنوز برای راهاندازی هیچ کتابخانهٔ بیرونی، Express، Docker، دیتابیس یا حساب ابری لازم نیست.
فایل app.mjs؛ کد کامل
import { createServer } from 'node:http';
import { AppError, makeStore } from './model.mjs';
const BODY_LIMIT = 8192;
function readJson(req) {
const type = (req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
if (type !== 'application/json' ||
(req.headers['content-encoding'] && req.headers['content-encoding'] !== 'identity')) {
req.resume();
return Promise.reject(new AppError(415, 'JSON_REQUIRED'));
}
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let settled = false;
const fail = error => { if (!settled) { settled = true; reject(error); } };
req.on('data', chunk => {
if (settled) return;
size += chunk.length;
if (size > BODY_LIMIT) {
chunks.length = 0;
fail(new AppError(413, 'BODY_TOO_LARGE'));
} else chunks.push(chunk);
});
req.on('error', () => fail(new AppError(400, 'BODY_INTERRUPTED')));
req.on('aborted', () => fail(new AppError(400, 'BODY_INTERRUPTED')));
req.on('end', () => {
if (settled) return;
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
const value = JSON.parse(text);
settled = true;
resolve(value);
} catch { fail(new AppError(400, 'INVALID_JSON')); }
});
});
}
function json(res, status, body, headers = {}) {
const data = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
...headers
});
res.end(data);
}
export function makeServer() {
const store = makeStore();
return createServer({ requestTimeout: 15000, headersTimeout: 10000, maxHeaderSize: 8192 },
async (req, res) => {
try {
const path = new URL(req.url, 'http://127.0.0.1').pathname;
const match = /^\/tasks\/([0-9a-f-]{36})$/.exec(path);
const allow = path === '/health' ? 'GET' : path === '/tasks' ? 'GET, POST' : match ? 'GET, PATCH' : '';
if (!allow) throw new AppError(404, 'ROUTE_NOT_FOUND');
if (!allow.split(', ').includes(req.method)) {
req.resume();
return json(res, 405, { error: 'METHOD_NOT_ALLOWED' }, { Allow: allow, Connection: 'close' });
}
if (path === '/health') return json(res, 200, { ok: true });
if (path === '/tasks' && req.method === 'GET') return json(res, 200, { tasks: store.list() });
if (path === '/tasks') {
const task = store.create(await readJson(req));
return json(res, 201, { task }, { Location: `/tasks/${task.id}` });
}
if (req.method === 'GET') return json(res, 200, { task: store.get(match[1]) });
const task = store.update(match[1], await readJson(req));
return json(res, 200, { task });
} catch (error) {
if (res.destroyed || res.writableEnded) return;
const known = error instanceof AppError;
json(res, known ? error.status : 500,
{ error: known ? error.message : 'INTERNAL_ERROR' }, { Connection: 'close' });
}
});
}
پاسخها از یک تابع json عبور میکنند تا نوع محتوا، طول بایتی و شکل خروجی هماهنگ بماند. Content-Length باید طول بایتهای UTF-8 باشد، نه طول رشتهٔ فارسی؛ به همین دلیل Buffer.byteLength استفاده شده است. no-store از نگهداری معمول پاسخ در کش جلوگیری میکند، ولی سامانهٔ مجوز یا محرمانگی نمیسازد. برای خطای ناشناخته فقط INTERNAL_ERROR میفرستیم و پشتهٔ خطا را داخل پاسخ کاربر قرار نمیدهیم؛ هنگام توسعه علت را با debugger در محیط محلی پیدا کنید.
چرا بدنه را محدود و جداگانه میخوانیم؟
readJson فقط application/json را میپذیرد و بدنهٔ فشرده خارج قرارداد است. سقف ۸۱۹۲ بایت روی دادهٔ واقعاً دریافتشده کنترل میشود؛ اعتمادکردن فقط به Content-Length برای درخواست chunked کافی نیست. پس از عبور از سقف، بخشهای قبلی از آرایهٔ موقت پاک میشوند و خطای ۴۱۳ آماده میشود. حالت settled مانع جمعآوری بیشتر و پاسخدادن دوباره است. پاسخ خطا اتصال را میبندد؛ این طراحی سادهٔ آزمایشگاهی را جای سامانهٔ کامل مقابله با سوءاستفاده نگذارید.
متن با TextDecoder در حالت fatal خوانده میشود تا UTF-8 خراب بیخبر جایگزین نشود. سپس JSON.parse شکل JSON را بررسی میکند و مدل، معنای فیلدها را. بنابراین JSON خراب ۴۰۰ است، ولی JSON درست با عنوان عددی ۴۲۲ میشود. این تفکیک کمک میکند مشتری بداند باید قالب ارسال را اصلاح کند یا مقدار فیلد را. بدنهٔ خالی با شیء خالی یکی نیست؛ بدنهٔ خالی در JSON.parse رد میشود، ولی شیء خالی JSON معتبر است و در مدل رد میشود.
فایل server.mjs؛ شروع و توقف
import { makeServer } from './app.mjs';
const raw = process.env.PORT ?? '3000';
if (!/^\d{1,5}$/.test(raw) || Number(raw) < 1 || Number(raw) > 65535) {
console.error('PORT must be an integer from 1 to 65535');
process.exitCode = 1;
} else {
const server = makeServer();
server.on('error', error => {
console.error(`Cannot start: ${error.code ?? 'UNKNOWN'}`);
process.exitCode = 1;
});
server.listen(Number(raw), '127.0.0.1', () => {
console.log(`Task API: http://127.0.0.1:${Number(raw)}/health`);
});
let stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
server.close();
const deadline = setTimeout(() => server.closeAllConnections(), 3000);
deadline.unref();
};
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
}
PORT در این پروژه فقط رقم ساده و عدد یک تا ۶۵۵۳۵ است؛ صفر در برنامهٔ اصلی پذیرفته نمیشود. آزمونها جداگانه از صفر استفاده میکنند تا سیستمعامل پورت آزاد بدهد. سرور عمداً فقط روی 127.0.0.1 گوش میدهد. این انتخاب برای کار محلی است و برای اجرای تمرین نیازی به بازکردن فایروال یا تغییر آدرس به 0.0.0.0 ندارید. برنامه پورت ۳۰۰۰ را پیشفرض میگیرد، ولی تنظیم نامعتبر را به عددی حدسی تبدیل نمیکند.
با Ctrl+C در همان ترمینال، سرور درخواست توقف میگیرد: پذیرش اتصال تازه متوقف میشود و برای پایان اتصالهای موجود فرصت کوتاهی داده میشود. مهلت سهثانیهای در این نمونه جلوی ماندن اتصالهای طولانی را میگیرد. رفتار سیگنالها میان سیستمعاملها دقیقاً یکسان نیست؛ انتظار روزمره در ویندوز همین توقف تعاملی از ترمینال خود برنامه است. بستن ناگهانی ترمینال هم دادهٔ حافظهای را از بین میبرد و این نسخه بازیابی خودکار ندارد.
اجرا در PowerShell
node --check model.mjs
node --check app.mjs
node --check server.mjs
npm.cmd startخروجی مورد انتظار Task API: http://127.0.0.1:3000/health است. بازماندن ترمینال نشانهٔ گیرکردن برنامه نیست؛ سرور منتظر درخواست است. در ترمینال دوم Invoke-RestMethod -Uri http://127.0.0.1:3000/health را اجرا کنید و ok برابر True ببینید. اگر برنامه Cannot start: EADDRINUSE نوشت، ابتدا نمونهٔ قبلی خودتان را با Ctrl+C متوقف کنید یا پورت دیگری انتخاب کنید. پایان برنامه با خطای شروع نباید با موفقیت اشتباه گرفته شود.
تمرین و پاسخ راهنما
به /unknown درخواست GET و به /tasks درخواست DELETE بدهید. اولی باید ۴۰۴ بدهد، چون مسیر نداریم؛ دومی باید ۴۰۵ و سرآیند Allow: GET, POST بدهد، چون مسیر وجود دارد و روش پشتیبانی نمیشود. هر دو را موفقیت حساب نکنید، حتی اگر سرور قطع نشده است. سپس POST بدون Content-Type درست را امتحان کنید؛ پاسخ ۴۱۵ است. معیار تحویل، توضیح تفاوت این سه خطا و اطمینان از زندهماندن /health پس از آنهاست.
مرجع رسمی
سرور، جریان درخواست و توقف اتصال در Node.js: https://nodejs.org/docs/latest-v24.x/api/http.html
رویدادهای فرایند و تفاوت سیستمعاملها: https://nodejs.org/docs/latest-v24.x/api/process.html#signal-events
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
متن درسهای عمومی را بدون ورود بخوانید.