منابع آموزشی و پژوهشی، برای دانشگاه و مدرسه
پشتیبانیانتشار محتوا
دورهٔ آموزشیرایگان

آموزش ساخت HTTP Server با Node.js؛ صفحه، API و TLS

۸ درس در ۴ فصل۹ ساعت و ۴۰ دقیقهدربارهٔ دوره
API و آزمون پاسخ‌هادرس ۴ از ۸

اجرای پروژه و آزمایش پاسخ‌ها؛ از مرورگر تا آزمون خودکار

۶۵ دقیقه آموزش متنی
در این درس ۴ بخش

در این درس، ساخته‌شدن فایل‌ها را به یک نتیجهٔ قابل مشاهده تبدیل می‌کنیم: صفحه باید باز شود، نام ساختگی پاسخ بگیرد و درخواست نادرست نیز نتیجهٔ مشخص داشته باشد. از همان پوشهٔ hello-http و فایل‌های دو درس قبل استفاده کنید. اجرای برنامه، آزمایش دستی و آزمون خودکار سه کار متفاوت‌اند؛ ممکن است فایل از نظر نحو درست باشد، اما روش پاسخ‌گویی با انتظار شما فرق کند. به همین دلیل هر مرحله یک نشانهٔ قبولی دارد. فعلاً روی دستگاه خودتان کار می‌کنیم؛ دامنه، Nginx و گواهی فصل بعد برای آزمایش این مرحله لازم نیستند.

نام فایل و پوشهٔ کاری را پیش از اجرای فرمان بررسی کنید

در VS Code پوشهٔ hello-http را باز کنید، فایل‌ها را با Ctrl+S ذخیره کنید و از Terminal یک PowerShell تازه بسازید. در ریشهٔ پروژه باید package.json، app.mjs، server.mjs و بعد از اضافه‌کردن بلوک آزمون همین درس، app.test.mjs دیده شوند. سه فایل صفحه داخل public قرار دارند. اشتباه رایج این است که server.mjs.txt ساخته شده یا package.json داخل public گذاشته شده است. Get-Location نشانی پوشهٔ کاری را نشان می‌دهد و Get-ChildItem نام فایل‌های همان محل را می‌خواند. اگر محلی که فرمان در آن اجرا می‌شود با پروژه فرق دارد، نخست آن را اصلاح کنید؛ نصب دوبارهٔ Node راه‌حل خطای مسیر نیست.

powershell
# روی رایانهٔ ویندوز، در PowerShell و داخل hello-http
Get-Location
Get-ChildItem
Get-ChildItem .\public
node --version
npm.cmd --version

فرمان‌های نسخه باید شماره نشان دهند. اگر node شناخته نمی‌شود، مرحلهٔ نصب و مسیر PATH را از آموزش قبلی تکمیل کنید. npm.cmd شکل صریح فرمان npm در ویندوز است و ابهام انتخاب npm.ps1 را کم می‌کند؛ برای این تمرین تغییر سراسری سیاست اجرای PowerShell لازم نیست. در ریشهٔ پروژه، دو فایل کمکی زیر را هم بسازید. نام فایل اول دقیقاً .env.example و دومی .gitignore است. نقطهٔ ابتدای نام بخشی از نام فایل است و باید در ویرایشگر دیده شود. این دو فایل خودشان سرور را اجرا نمی‌کنند.

text
PORT=3000
text
.env
node_modules/
*.log
coverage/

env.example فقط نمونهٔ تنظیم است؛ اسکریپت start آن را خودکار بارگیری نمی‌کند. برنامهٔ فعلی بدون تنظیم اضافه روی پورت ۳۰۰۰ اجرا می‌شود. اگر بعداً فایل .env ساختید، بارگیری آن باید در دستور یا مدیر سرویس صریح باشد؛ پنهان‌کردن این مرحله یکی از علت‌های رایج «چرا پورت عوض نشد؟» است. gitignore هم به Git می‌گوید فایل محیطی، گزارش و خروجی‌های مشخص را به‌طور معمول وارد نسخه‌بندی نکند. این فایل رمزگذاری یا مجوز فایل ایجاد نمی‌کند و اطلاعاتی را که قبلاً منتشر شده‌اند پس نمی‌گیرد. در این پروژه هیچ رمز واقعی لازم نداریم.

فایل آزمون را اضافه کنید؛ بعد برنامه را اجرا کنید

آزمون خودکار یعنی انتظارها را به کدی تبدیل کنیم که بتوان دوباره اجرا کرد. فایل کامل زیر را با نام app.test.mjs در ریشهٔ پروژه بسازید. در آن، تابع‌های کوچک آزمون (#function) درخواست واقعی به یک نمونهٔ محلی برنامه می‌فرستند؛ assert بررسی می‌کند نتیجه با قرارداد برابر باشد. برنامهٔ آزمون با پورت صفر شروع می‌شود تا سیستم یک پورت آزاد انتخاب کند و اجرای معمول ۳۰۰۰ را اشغال نکند. پس از آزمون نیز همان نمونه بسته می‌شود. آزمون موفق تضمین همهٔ شرایط اینترنت نیست، اما تغییر ناخواستهٔ مسیرها، بدنه و نوع پاسخ را زودتر آشکار می‌کند.

javascript
import test, { before, after } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { once } from 'node:events';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createApp } from './app.mjs';

let server;
let base;
before(async () => {
  server = await createApp();
  server.listen(0, '127.0.0.1');
  await once(server, 'listening');
  base = `http://127.0.0.1:${server.address().port}`;
});
after(async () => {
  server.closeAllConnections();
  await new Promise(resolve => server.close(resolve));
});

async function post(value) {
  return fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(value)
  });
}

test('home is a real Persian RTL HTML document', async () => {
  const res = await fetch(base);
  assert.equal(res.status, 200);
  assert.match(res.headers.get('content-type'), /^text\/html; charset=utf-8$/);
  const html = await res.text();
  assert.match(html, /lang="fa" dir="rtl"/);
  assert.match(html, /سلام، <span>سرور!/);
  assert.match(html, /src="\/app.js"/);
});

test('only local script and style assets are served with correct types', async () => {
  for (const [path, type] of [['/app.js', 'text/javascript'], ['/style.css', 'text/css']]) {
    const res = await fetch(base + path);
    assert.equal(res.status, 200);
    assert.ok(res.headers.get('content-type').startsWith(type));
    assert.ok((await res.text()).length > 100);
  }
});

test('responses have a self-only CSP and nosniff', async () => {
  const res = await fetch(base);
  assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
  assert.match(res.headers.get('content-security-policy'), /script-src 'self'/);
  assert.match(res.headers.get('content-security-policy'), /frame-ancestors 'none'/);
  assert.equal(res.headers.get('cache-control'), 'no-store');
});

test('status API gives only the documented data', async () => {
  const res = await fetch(base + '/api/status');
  const data = await res.json();
  assert.equal(res.status, 200);
  assert.deepEqual(Object.keys(data).sort(), ['ok', 'service', 'uptimeSeconds']);
  assert.equal(data.ok, true);
  assert.equal(data.service, 'darsavand-hello');
  assert.ok(Number.isSafeInteger(data.uptimeSeconds) && data.uptimeSeconds >= 0);
});

test('HEAD home gives headers without a response body', async () => {
  const res = await fetch(base, { method: 'HEAD' });
  assert.equal(res.status, 200);
  assert.ok(Number(res.headers.get('content-length')) > 0);
  assert.equal(await res.text(), '');
});

test('HEAD status is supported', async () => {
  const res = await fetch(base + '/api/status', { method: 'HEAD' });
  assert.equal(res.status, 200);
  assert.equal(await res.text(), '');
});

test('a Persian name is trimmed and returned as valid JSON', async () => {
  const res = await post({ name: '  سارا  ' });
  assert.equal(res.status, 200);
  assert.equal((await res.json()).message, 'سلام، سارا! این پاسخ از Node.js آمده است.');
});

test('the documented 60-code-unit boundary is accepted', async () => {
  const res = await post({ name: 'س'.repeat(60) });
  assert.equal(res.status, 200);
  assert.ok((await res.json()).message.includes('س'.repeat(60)));
});

test('an overlong name is rejected with 422', async () => {
  const res = await post({ name: 'س'.repeat(61) });
  assert.equal(res.status, 422);
  assert.equal((await res.json()).error, 'INVALID_NAME');
});

test('a blank name is rejected', async () => {
  const res = await post({ name: '   ' });
  assert.equal(res.status, 422);
});

test('a number is not silently converted into a name', async () => {
  const res = await post({ name: 123 });
  assert.equal(res.status, 422);
});

test('null, arrays and objects without exactly one name are rejected', async () => {
  for (const input of [null, [], {}, { name: 'سارا', admin: true }]) {
    const res = await post(input);
    assert.equal(res.status, 422);
    assert.equal((await res.json()).error, 'INVALID_FIELDS');
  }
});

test('HTML-like input remains JSON text, never executable HTML', async () => {
  const name = '<img src=x onerror=alert(1)>';
  const res = await post({ name });
  assert.equal(res.status, 200);
  assert.match(res.headers.get('content-type'), /^application\/json/);
  assert.equal((await res.json()).message, `سلام، ${name}! این پاسخ از Node.js آمده است.`);
});

test('malformed JSON gives 400', async () => {
  const res = await fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{bad'
  });
  assert.equal(res.status, 400);
  assert.equal((await res.json()).error, 'INVALID_JSON');
});

test('an empty body gives 400', async () => {
  const res = await fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: ''
  });
  assert.equal(res.status, 400);
});

test('invalid UTF-8 is rejected instead of replaced silently', async () => {
  const res = await fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: Buffer.from([0x7b, 0x22, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x22, 0xff, 0x22, 0x7d])
  });
  assert.equal(res.status, 400);
});

test('a non-JSON content type gives 415', async () => {
  const res = await fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: 'Sara'
  });
  assert.equal(res.status, 415);
});

test('an unsupported compressed body gives 415', async () => {
  const res = await fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' },
    body: JSON.stringify({ name: 'Sara' })
  });
  assert.equal(res.status, 415);
});

test('an oversized body gives 413', async () => {
  const res = await fetch(base + '/api/greet', {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: ' '.repeat(1025)
  });
  assert.equal(res.status, 413);
});

test('a chunked oversized request receives 413 before the sender ends it', async () => {
  await new Promise((resolve, reject) => {
    const req = request(base + '/api/greet', {
      method: 'POST', headers: { 'Content-Type': 'application/json' }
    });
    const deadline = setTimeout(() => { req.destroy(); reject(new Error('No early 413')); }, 3000);
    req.on('error', error => { clearTimeout(deadline); reject(error); });
    req.on('response', res => {
      const chunks = [];
      res.on('data', chunk => chunks.push(chunk));
      res.on('end', () => {
        clearTimeout(deadline);
        try {
          assert.equal(res.statusCode, 413);
          assert.equal(JSON.parse(Buffer.concat(chunks)).error, 'BODY_TOO_LARGE');
          req.destroy(); resolve();
        } catch (error) { req.destroy(); reject(error); }
      });
    });
    req.write('x'.repeat(1200)); // Deliberately do not call end().
  });
});

test('known routes reject an unsupported method and return Allow', async () => {
  for (const [path, method, allow] of [
    ['/api/greet', 'GET', 'POST'], ['/api/status', 'POST', 'GET, HEAD'], ['/', 'DELETE', 'GET, HEAD']
  ]) {
    const res = await fetch(base + path, { method });
    assert.equal(res.status, 405);
    assert.equal(res.headers.get('allow'), allow);
  }
});

test('unknown routes do not expose code or dotfiles', async () => {
  for (const path of ['/missing', '/.env', '/server.mjs', '/package.json', '/..%2fserver.mjs']) {
    const res = await fetch(base + path);
    assert.equal(res.status, 404);
    assert.equal((await res.json()).error, 'NOT_FOUND');
  }
});

test('invalid PORT values stop before creating a listener', () => {
  for (const port of ['', '0', '-1', '65536', '3.5', 'nope']) {
    const result = spawnSync(process.execPath, [fileURLToPath(new URL('./server.mjs', import.meta.url))], {
      env: { ...process.env, PORT: port }, encoding: 'utf8', timeout: 3000
    });
    assert.equal(result.status, 1);
    assert.match(result.stderr, /PORT must be/);
  }
});

اکنون check را اجرا کنید. اگر خطای نحو یا فایل پیدا نشد دیدید، همان فایل و همان خط را اصلاح کنید و دوباره همین بررسی را بگیرید. فقط بعد از موفقیت، test را اجرا کنید. نتیجهٔ قابل قبول آزمون‌ها، صفر بودن شمار شکست‌ها و کد خروج صفر است؛ شمارهٔ دقیق آزمون‌ها را معیار هویتی پروژه نگیرید. در PowerShell می‌توانید بلافاصله بعد از هر فرمان بومی، $LASTEXITCODE را ببینید. هنوز چیزی روی پورت ۳۰۰۰ منتشر نشده است. پس از موفقیت هر دو مرحله، فرمان start را در همان ترمینال اجرا و آن پنجره را برای سرور باز نگه دارید.

powershell
# PowerShell در ریشهٔ پروژه؛ هر مرحله پس از موفقیت قبلی
npm.cmd run check
# اگر بررسی موفق بود:
npm.cmd test
powershell
# PowerShell؛ پس از موفقیت check و test
npm.cmd start

وقتی نشانی http://127.0.0.1:3000/ چاپ شد، همان نشانی را در مرورگر باز کنید؛ روی خود فایل HTML دوبار کلیک نکنید. file:// یعنی خواندن فایل از دیسک، نه درخواست از وب‌سرور ما. در این پروژه صفحه و API از یک مبدأ ارائه می‌شوند؛ افزودن مجوز CORS گسترده یا خاموش‌کردن حفاظت مرورگر، راه درست اصلاح اجرای اشتباه نیست. برای آزمایش با فرمان، ترمینال دومی باز کنید تا ترمینال اول سرور را زنده نگه دارد. اگر قبلاً آن را بسته‌اید و اتصال رد می‌شود، ابتدا اجرای برنامه را بررسی کنید؛ خطای شبکه هنوز پاسخ HTTP برنامه نیست.

روی اوبونتو نیز همین فایل‌ها اجرا می‌شوند، اما دستورهای شل ویندوز را در Bash ننویسید. اگر runtime دورهٔ قبلی را در مسیر ثابت نصب کرده‌اید، بلوک زیر را در Bash و با حساب معمولی خودتان اجرا کنید. مسیر پوشهٔ شخصی باید محل واقعی پروژهٔ شما باشد. set -e باعث می‌شود در این بلوک، شکست آزمون اجازهٔ رفتن به اجرای بعدی را ندهد. نبود /opt/node-current/bin/node یعنی پیش‌نیاز نصب مسیر ثابت هنوز انجام نشده است؛ فایل اجرایی ناشناس را صرفاً برای عبور از این خطا جایگزین نکنید. اجرای این برنامهٔ محلی به sudo نیاز ندارد.

bash
# Bash روی اوبونتو؛ runtime مسیر ثابت قبلاً نصب شده باشد
(
  set -e
  cd "$HOME/hello-http"
  /opt/node-current/bin/node --test
  /opt/node-current/bin/node server.mjs
)

درخواست موفق و شکست مشخص را از ترمینال ببینید

در PowerShell دوم، نخست وضعیت را بخوانید و سپس نام ساختگی بفرستید. ConvertTo-Json از شیء، متن #json می‌سازد. تبدیل صریح آن متن به بایت UTF-8 کمک می‌کند ارسال فارسی در نسخه‌های مختلف PowerShell به پیش‌فرض مبهم وابسته نباشد. Content-Type نیز قالب درخواست را روشن می‌کند. Invoke-RestMethod پاسخ JSON را به شیء تبدیل می‌کند، پس می‌توانید ویژگی message را بخوانید. اگر یک فرمان خطای HTTP داد، شمارهٔ وضعیت و پیام را بررسی کنید؛ پاسخ ۴۲۲ برای نام نامعتبر را با قطع اتصال یا نبودن خود برنامه یکی نگیرید.

powershell
# PowerShell دوم؛ سرور در ترمینال اول روشن باشد
$base = 'http://127.0.0.1:3000'
Invoke-RestMethod -Uri "$base/api/status" -Method Get -TimeoutSec 10
$payload = @{ name = 'سارا' } | ConvertTo-Json -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
$reply = Invoke-RestMethod -Uri "$base/api/greet" -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10
$reply.message

خروجی اول باید ok برابر true، نام service و عدد ثانیهٔ غیرمنفی نشان دهد؛ عدد ثانیه در هر اجرا ممکن است فرق کند. خروجی دوم باید سلام به سارا و عبارت «این پاسخ از Node.js آمده است» باشد. حالا همین نام را از فرم صفحه بفرستید و نتیجه را مقایسه کنید. هر دو کلاینت به یک API وصل‌اند. متن پاسخ در رابط باید به‌عنوان متن دیده شود، نه HTML اجرایی. در آزمایش عمومی از اطلاعات شخصی واقعی استفاده نکنید؛ برای فهم مسیر درخواست، همان نام ساختگی کافی است.

bash
# Bash روی همان دستگاهی که سرور اجراست؛ هر خط یک آزمایش مستقل
curl -i --max-time 10 http://127.0.0.1:3000/missing
curl -i --max-time 10 http://127.0.0.1:3000/api/greet
curl -i --max-time 10 -H 'Content-Type: application/json' --data-binary '{bad' http://127.0.0.1:3000/api/greet
curl -i --max-time 10 -H 'Content-Type: application/json' --data-binary '{"name":123}' http://127.0.0.1:3000/api/greet

چهار خط باید به‌ترتیب ۴۰۴، ۴۰۵، ۴۰۰ و ۴۲۲ بدهند. -i سرآیند و شمارهٔ وضعیت را نشان می‌دهد؛ اینجا عمداً از حالت توقف روی خطای HTTP استفاده نکرده‌ایم تا پاسخ ناموفق را بخوانیم. موفقیت اجرای ابزار curl به‌تنهایی به معنی موفقیت درخواست برنامه نیست. اگر همین کدهای وضعیت را در تست خودکار می‌بینید، آزمون احتمالاً انتظار همان شکست کنترل‌شده را دارد. اگر تنها صفحه باز می‌شود ولی پیام سلام نمی‌آید، در ابزار توسعهٔ مرورگر بخش Network، درخواست api/greet و وضعیتش را ببینید؛ #debugging-console برای خواندن خطای اجرا نیز مرجع مرتبط همین مهارت است.

ده سناریوی تحویل و پاسخ راهنما

برای تحویل، این ده مورد را اجرا و نتیجه را یادداشت کنید: ۱) صفحهٔ اصلی باز شود؛ ۲) دکمهٔ وضعیت پاسخ بگیرد؛ ۳) نام فارسی معمولی پیام سلام بگیرد؛ ۴) فاصلهٔ دو سر نام حذف شود؛ ۵) نام خالی پذیرفته نشود؛ ۶) عدد به‌جای نام رد شود؛ ۷) JSON شکسته با ۴۰۰ رد شود؛ ۸) GET روی مسیر خوشامدگویی ۴۰۵ بگیرد؛ ۹) مسیر ناشناخته ۴۰۴ باشد؛ ۱۰) پس از Ctrl+C در ترمینال سرور، درخواست تازه موفق نشود. پاسخ راهنما این است که چهار مورد نخست مسیر موفق‌اند، مورد پنجم و ششم قرارداد داده‌اند، هفتم قالب پیام است، هشتم روش درخواست است، نهم مسیریابی و دهم چرخهٔ اجرای برنامه را می‌سنجد. شکست مورد دهم در این آزمایش همان نتیجهٔ مورد انتظار است.

اگر یکی از انتظارها برآورده نشد، نام فایل تغییرکرده، فرمان، نشانی و خروجی واقعی را کنار هم بنویسید و فقط یک علت محتمل را بررسی کنید. پاک‌کردن catch یا حذف آزمون شکست‌خورده، قرارداد را اصلاح نمی‌کند. وقتی همهٔ موارد درست‌اند، از فایل‌ها نسخهٔ مشخص نگه دارید و سپس فصل اجرای اوبونتو را شروع کنید. راهنمای ابزار و اجرای فایل در درس فضای کار ویندوز آمده است: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=5c9fbc95-bdce-46f2-87a9-62b86110a926
برای تفاوت خطای اتصال و خطای پاسخ، درس عیب‌یابی Node را مرور کنید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=7f46ca6f-c14d-45dd-b9b9-2ef88ae773aa
در مرحلهٔ بعد این برنامه با کاربر اختصاصی و سرویس اجرا می‌شود؛ موفقیت محلی هنوز به معنی نصب HTTPS یا آماده‌بودن دامنه نیست.

منابع رسمی انگلیسی:
Node.js test runner: https://nodejs.org/docs/latest-v24.x/api/test.html
Node.js command line: https://nodejs.org/docs/latest-v24.x/api/cli.html
Microsoft Invoke-RestMethod 5.1: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/invoke-restmethod?view=powershell-5.1
MDN HTTP status codes: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status

تألیف، مثال‌ها و توضیح فارسی: تحریریه درس‌آوند. منابع رسمی انگلیسی برای بررسی رفتار ابزارها و مطالعهٔ بیشتر آمده‌اند.

بستهٔ کامل پروژه به‌صورت رایگان در صفحهٔ اختصاصی پروژه قرار می‌گیرد. همهٔ فایل‌های ضروری در باکس‌های همین آموزش نیز آمده‌اند:
https://www.darsavand.com/resources/hello-node-http-project

ورود برای ثبت پیشرفت

متن درس‌های عمومی را بدون ورود بخوانید.

یادگیری‌ات را دنبال کن

برای نگهداری پیشرفت و ادامهٔ یادگیری از حساب خود استفاده کن.

ورود و ثبت‌نام
دربارهٔ این دوره

پروژهٔ رایگان «سلام، سرور!» در ۸ درس؛ ساخت یک صفحهٔ فارسی و API با Node.js، اجرای اوبونتو و HTTPS با Let’s Encrypt یا نصب دستی گواهی، همراه کد کامل و آزمون.

پرسشی دربارهٔ این درس داری؟گفت‌وگو با پشتیبانی در حساب کاربری
یکجا انتخاب کن، راحت‌تر دریافت کن

انتخاب‌های من ۰ منبع

مجموعهٔ مطالعه‌ات را بساز.

کنار مقاله‌ها «انتخاب» را بزنید تا چند فایل را با هم دریافت کنید.

انتخاب مقاله‌های رایگان