از پروژهٔ ویندوز تا کد قابلانتقال؛ ساخت و شناخت API مدیریت کارها
در این درس ۳ بخش
در این درس چیزی را منتقل میکنیم که میتوانیم رفتار آن را توضیح دهیم. «استقرار» یعنی قراردادن نسخهای مشخص از برنامه و تنظیمات لازم در محیطی که قرار است به آن خدمت بدهد؛ با کپی چند فایل یا اجرای اتفاقی یک فرمان تمام نمیشود. برنامهٔ ما همان API مدیریت کارهای دورهٔ Node.js در ویندوز است. API در اینجا مجموعهای از مسیرها و قواعد درخواست و پاسخ است که یک کلاینت از راه HTTP به آن دسترسی پیدا میکند. هدف، تمرین استقرار این برنامه روی اوبونتو است؛ برنامهٔ تجاری دارای حساب کاربری یا پایگاه داده نمیسازیم. پیش از ادامه باید اتصال SSH درس دوم و نصب Node درس سوم موفق باشد. فعلاً فایلها را در ویندوز آماده میکنیم و به پوشهٔ شخصی مدیر روی اوبونتو میفرستیم؛ اجرای دائمی در دو درس بعد انجام میشود.
برای اتصال این مسیر به آموختههای قبلی، ابتدا درس «طراحی API مدیریت کارها» را مرور کنید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=d92ac7ae-01d1-47b9-8e1a-884af8e80fe3
سپس توضیح مرز مرورگر و ماژول Node را ببینید: https://www.darsavand.com/courses/nodejs-windows-backend-start?lesson=95a58da2-0429-4299-a676-ed15589c1e4f
در کد از #modules-import-export برای جداکردن مسئولیتها استفاده میشود. این جداسازی فقط مرتبکردن ظاهر فایل نیست: مدل داده باید بتواند بدون بالاآوردن سرور آزمایش شود و سازندهٔ سرور نباید به پورت ثابتی وابسته باشد. اگر اکنون تشخیص نمیدهید کدام تابع داده را تغییر میدهد و کدام تابع پاسخ HTTP میسازد، پیش از انتقال، همین نقشه را روی کاغذ رسم کنید. جابهجایی به لینوکس ضعف در قرارداد برنامه را برطرف نمیکند؛ فقط محیط اجرا را عوض میکند.
قرارداد برنامه را پیش از فایلها بخوانید
یک «کار» سه ویژگی دارد: id شناسهٔ ساختهشده توسط سرور، title عنوان متنی و done وضعیت انجامشدن. شناسهٔ UUID برای تمایز ردیفهاست و رمز دسترسی محسوب نمیشود. GET /health پاسخ سلامت ساده میدهد؛ GET /tasks فهرست را میخواند؛ POST /tasks با بدنهٔ دارای فقط title کار میسازد؛ GET /tasks/شناسه یک ردیف را میخواند و PATCH همان مسیر با فقط done، وضعیتش را تغییر میدهد. PATCH در این قرارداد اجازهٔ تغییر هر ویژگی دلخواهی را نمیدهد. done باید مقدار بولی واقعی باشد، نه متن «true». مفهوم #validation-contracts یعنی همین مرز قابلتوضیح: پیش از تغییر وضعیت، شکل، نوع و محدودیت داده بررسی شود. فرستادن JSON معتبر از نظر نحوی هنوز به معنی پذیرفتهشدن آن توسط مدل نیست.
کد وضعیت HTTP خلاصهٔ نتیجه است. ایجاد موفق ۲۰۱ و خواندن موفق ۲۰۰ است. نشانی ناشناخته یا شناسهٔ پیدانشده ۴۰۴، روش نامجاز ۴۰۵، JSON خراب یا UTF-8 نامعتبر ۴۰۰، نوع محتوای نامناسب ۴۱۵ و ویژگی یا مقدار ناسازگار ۴۲۲ میدهد. سقف بدنه ۸۱۹۲ بایت است؛ متن فارسی میتواند برای هر نویسه چند بایت لازم داشته باشد. عبور از این سقف ۴۱۳ و رسیدن مجموعه به هزار کار ۴۰۹ میدهد. این محدودیتها سیاست تمریناند و نسخهٔ عمومی همهٔ محصولها نیستند. مسیر موفق و شکست را کنار هم بخوانید تا اگر بعداً Nginx پاسخ ۴۰۳ داد، آن را با ۴۲۲ مدل یا خرابی اتصال اشتباه نگیرید. کد پاسخ یک سرنخ است؛ متن خطای کنترلشده و لایهای که آن را ساخته نیز اهمیت دارد.
پوشهٔ ویندوز و فایلهای کامل پروژه
اکنون روی رایانهٔ خود، در PowerShell معمولی اجرا کنید؛ این بلوک برای شل اوبونتو نیست. یک پوشهٔ تازه بسازید و همان پوشه را از File > Open Folder در VS Code باز کنید. اگر Node Ubuntu Lab از قبل وجود دارد، محتوایش را بررسی کنید و برای این نوبت پوشهٔ تازهای با نام دیگر انتخاب کنید؛ چیزی را کورکورانه پاک نکنید. هر بلوک بعدی، محتوای کامل یک فایل با نام مشخص است. فایل را در همان ریشه بسازید و با UTF-8 ذخیره کنید؛ کدهای چند فایل را در یک فایل به هم نچسبانید. پسوند واقعی .mjs را نگه دارید، نه .mjs.txt. در این مرحله به ترمینال مدیر، Docker یا نصب وابستگی خارجی نیاز نداریم. نسخهٔ Node را هم ثبت کنید تا نتیجهٔ آزمون به محیطی مشخص مربوط باشد.
$appDir = Join-Path $HOME 'Node Ubuntu Lab'
New-Item -ItemType Directory -Path $appDir -ErrorAction Stop | Out-Null
Set-Location -LiteralPath $appDir
node --version
Get-Locationفایل package.json شناسنامهٔ قابلخواندن پروژه و محل نامگذاری دستورهاست؛ خودش کد سرور را اجرا نمیکند. private مانع انتشار تصادفی همین بسته با npm publish میشود، اما کنترل دسترسی وبسرور نیست. type مقدار module دارد و فایلهای ما نیز با پسوند صریح mjs نوشته شدهاند. engines انتظار نسخهٔ اجرا را بیان میکند و بهتنهایی Node را نصب یا تعویض نمیکند. دستور check نحو فایلها را میخواند، test قراردادها را اجرا میکند و start برنامهٔ اصلی را شروع میکند. این نسخه وابستگی خارجی ندارد؛ نبود node_modules عیب نیست. اگر پروژهٔ واقعی وابستگی داشت، فایل قفل و روش نصب تکرارپذیر آن هم بخشی از بستهٔ انتشار میشد. در این درس هیچ بستهای را صرفاً برای پرکردن پوشه نصب نمیکنیم. محتوای کامل فایل package.json:
{
"name": "darsavand-node-task-lab",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"start": "node server.mjs",
"start:env": "node --env-file=.env server.mjs",
"check": "node --check app.mjs && node --check model.mjs && node --check server.mjs && node --check app.test.mjs",
"test": "node --test app.test.mjs"
}
}فایل model.mjs مسئول وضعیت کارهاست. Map یک مجموعهٔ کلید و مقدار در حافظهٔ همین فرایند است. makeStore یک مجموعهٔ مستقل میسازد و متدهای خواندن، ایجاد و ویرایش را برمیگرداند. exactObject فقط ویژگی مورد انتظار را میپذیرد؛ بنابراین دادهای مثل admin: true با گسترش بیبررسی وارد مدل نمیشود. #object و #boolean در اینجا مفاهیم عملیاند: شیء باید شکل مشخص داشته باشد و وضعیت باید بولی باشد. خروجیها کپی میشوند تا تغییر شیء برگرداندهشده، بیاجازه وضعیت داخلی را تغییر ندهد. این مدل ذخیرهٔ ماندگار ندارد؛ با بستهشدن برنامه همهٔ کارها از بین میروند. پیش از هر تمرین، این محدودیت را بپذیرید و فقط دادهٔ ساختگی وارد کنید. محتوای کامل فایل model.mjs:
import { randomUUID } from 'node:crypto';
export class AppError extends Error {
constructor(status, code) { super(code); this.status = status; }
}
function exactObject(value, key) {
if (!value || typeof value !== 'object' || Array.isArray(value) ||
Object.keys(value).length !== 1 || !Object.hasOwn(value, key)) {
throw new AppError(422, 'INVALID_FIELDS');
}
}
export function makeStore() {
const tasks = new Map();
return {
list() { return [...tasks.values()].map(task => ({ ...task })); },
get(id) {
const task = tasks.get(id);
if (!task) throw new AppError(404, 'TASK_NOT_FOUND');
return { ...task };
},
create(input) {
exactObject(input, 'title');
if (typeof input.title !== 'string') throw new AppError(422, 'INVALID_TITLE');
const title = input.title.trim();
if (title.length < 2 || title.length > 100) throw new AppError(422, 'INVALID_TITLE');
if (tasks.size >= 1000) throw new AppError(409, 'TASK_LIMIT');
const task = { id: randomUUID(), title, done: false };
tasks.set(task.id, task);
return { ...task };
},
update(id, input) {
exactObject(input, 'done');
if (typeof input.done !== 'boolean') throw new AppError(422, 'INVALID_DONE');
const current = this.get(id);
const task = { ...current, done: input.done };
tasks.set(id, task);
return { ...task };
}
};
}فایل app.mjs مرز HTTP است. بدنهٔ درخواست یک جریان بایت است، نه شیء آماده؛ readJson نوع محتوا، فشردهنبودن و حجم آن را بررسی میکند و پس از خواندن، #json را تجزیه میکند. بدنهٔ خالی در تجزیه رد میشود، اما شیء خالی JSON معتبر است و بعداً در مدل رد میشود. تابع json سرآیند و کد پاسخ را هماهنگ میسازد و end پایان بدنه را مشخص میکند. هر درخواست باید فقط یک پاسخ نهایی داشته باشد. سازندهٔ سرور هنوز listen را صدا نمیزند؛ همین موضوع اجازه میدهد آزمونها پورت آزاد موقت انتخاب کنند. این طراحی را با «یک برنامه برای هر درخواست» اشتباه نگیرید؛ یک فرایند میتواند درخواستهای متعدد را مدیریت کند و وضعیت Map آن فرایند مشترک است. محتوای کامل فایل 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' });
}
});
}فایل server.mjs نقطهٔ ورود اجراست. PORT از محیط خوانده و پیش از listen بررسی میشود؛ ورودی خالی یا متن نامناسب نباید بیصدا به پورتی ناخواسته تبدیل شود. آدرس ۱۲۷.۰.۰.۱ عمداً ثابت است تا فعلاً فقط همان سرور بتواند به پورت ۳۰۰۰ وصل شود. در فصل Nginx، واسط عمومی درخواستهای مجاز را به این نشانی داخلی میفرستد. شنیدن روی صفرِ همهٔ رابطها را راهحل هر خطای اتصال ندانید. بخش stop نیز با رسیدن سیگنال توقف، پذیرش اتصال تازه را میبندد و پس از مهلت محدود، اتصال باقیمانده را قطع میکند. این رفتار برای پایان قابلپیشبینی است و تضمین ذخیرهٔ دادهٔ حافظهای نیست. محتوای کامل فایل 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);
}فایل app.test.mjs فقط توضیح تست نیست؛ مجموعهٔ قابل اجرای آزمونهاست. هر آزمون HTTP سرور مستقلی روی پورت آزاد میسازد و در پایان آن را میبندد، پس برای اجرا لازم نیست سرور اصلی روی ۳۰۰۰ روشن باشد. بررسی سلامت، ایجاد و ویرایش، دادهٔ فارسی، JSON خراب، حجم زیاد، نوع نامناسب و ثابتماندن داده پس از شکست، انتظارهای مهم ما هستند. یک نتیجهٔ سبز به معنی بررسی امنیت کل سیستم یا تحمل بار واقعی نیست؛ یعنی همین قراردادهای ثبتشده در همین محیط گذشتهاند. اگر آزمون شکست خورد، نام مورد و انتظارش را بخوانید و پیش از رفتن به سرور علت را پیدا کنید. حذف آزمون شکستخورده بدون فهم رفتار، مشکل استقرار را به مرحلهٔ بعد منتقل میکند. محتوای کامل فایل app.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { request } from 'node:http';
import { makeServer } from './app.mjs';
import { makeStore } from './model.mjs';
async function withServer(run) {
const server = makeServer();
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
try { await run(base); }
finally { await new Promise(resolve => server.close(resolve)); }
}
const post = (base, body, headers = { 'Content-Type': 'application/json' }) =>
fetch(`${base}/tasks`, { method: 'POST', headers, body });
test('health, JSON type and no-store', () => withServer(async base => {
const response = await fetch(`${base}/health`);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /application\/json/);
assert.equal(response.headers.get('cache-control'), 'no-store');
assert.deepEqual(await response.json(), { ok: true });
}));
test('new server starts with empty list', () => withServer(async base => {
assert.deepEqual(await (await fetch(`${base}/tasks`)).json(), { tasks: [] });
}));
test('create, list, read and update a Persian task', () => withServer(async base => {
const created = await post(base, JSON.stringify({ title: ' یادگیری سرور ' }));
assert.equal(created.status, 201);
const { task } = await created.json();
assert.equal(task.title, 'یادگیری سرور');
assert.equal(task.done, false);
assert.match(task.id, /^[0-9a-f-]{36}$/);
assert.equal(created.headers.get('location'), `/tasks/${task.id}`);
assert.deepEqual((await (await fetch(base + created.headers.get('location'))).json()).task, task);
const changed = await fetch(`${base}/tasks/${task.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: '{"done":true}'
});
assert.equal(changed.status, 200);
assert.equal((await changed.json()).task.done, true);
assert.equal((await (await fetch(`${base}/tasks`)).json()).tasks[0].done, true);
}));
test('malformed JSON is 400 and does not create data', () => withServer(async base => {
assert.equal((await post(base, '{')).status, 400);
assert.deepEqual(await (await fetch(`${base}/tasks`)).json(), { tasks: [] });
}));
test('empty JSON body is 400', () => withServer(async base => {
assert.equal((await post(base, '')).status, 400);
}));
test('invalid UTF-8 is 400', () => withServer(async base => {
assert.equal((await post(base, Buffer.from([0xff]))).status, 400);
}));
test('wrong content type is 415', () => withServer(async base => {
assert.equal((await post(base, '{"title":"hello"}', { 'Content-Type': 'text/plain' })).status, 415);
}));
test('compressed body is outside this contract', () => withServer(async base => {
assert.equal((await post(base, '{}', {
'Content-Type': 'application/json', 'Content-Encoding': 'gzip'
})).status, 415);
}));
test('body limit is bytes, including multibyte Persian', () => withServer(async base => {
const body = JSON.stringify({ title: 'س'.repeat(5000) });
assert.ok(Buffer.byteLength(body) > 8192);
assert.equal((await post(base, body)).status, 413);
}));
test('chunked oversized body receives 413 JSON', () => withServer(base => new Promise((resolve, reject) => {
const req = request(`${base}/tasks`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }
}, response => {
let body = '';
response.setEncoding('utf8');
response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try {
assert.equal(response.statusCode, 413);
assert.equal(JSON.parse(body).error, 'BODY_TOO_LARGE');
resolve();
} catch (error) { reject(error); }
});
});
req.on('error', reject);
req.write('x'.repeat(5000));
req.end('x'.repeat(5000));
})));
for (const [name, input] of [
['array', []], ['null', null], ['number title', { title: 42 }],
['blank title', { title: ' ' }], ['long title', { title: 'x'.repeat(101) }],
['extra field', { title: 'hello', admin: true }]
]) {
test(`reject ${name} with 422`, () => withServer(async base => {
assert.equal((await post(base, JSON.stringify(input))).status, 422);
}));
}
test('unknown route is 404', () => withServer(async base => {
assert.equal((await fetch(`${base}/unknown`)).status, 404);
}));
test('unsupported method has 405 and Allow header', () => withServer(async base => {
const response = await fetch(`${base}/tasks`, { method: 'DELETE' });
assert.equal(response.status, 405);
assert.equal(response.headers.get('allow'), 'GET, POST');
}));
test('missing task is 404', () => withServer(async base => {
assert.equal((await fetch(`${base}/tasks/00000000-0000-0000-0000-000000000000`)).status, 404);
}));
test('done must be boolean, rejected update preserves task', () => withServer(async base => {
const { task } = await (await post(base, '{"title":"hello"}')).json();
const response = await fetch(`${base}/tasks/${task.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: '{"done":"true"}'
});
assert.equal(response.status, 422);
assert.equal((await (await fetch(`${base}/tasks/${task.id}`)).json()).task.done, false);
}));
test('each server owns its own in-memory state', async () => {
await withServer(async base => { assert.equal((await post(base, '{"title":"hello"}')).status, 201); });
await withServer(async base => { assert.deepEqual(await (await fetch(`${base}/tasks`)).json(), { tasks: [] }); });
});
test('store returns copies and enforces maximum count', () => {
const store = makeStore();
const first = store.create({ title: 'hello' });
first.title = 'changed outside';
assert.equal(store.get(first.id).title, 'hello');
const list = store.list(); list[0].done = true;
assert.equal(store.get(first.id).done, false);
for (let i = 1; i < 1000; i++) store.create({ title: `task ${i}` });
assert.throws(() => store.create({ title: 'overflow' }), error => error.status === 409);
});دو فایل کوچک باقی ماندهاند. .env.example نمونهٔ تنظیم است و در آن فقط پورت آموزشی قرار میگیرد؛ این فایل رمز واقعی ندارد. برنامه در حالت start:env فایل .env را میخواند، ولی در سرویس systemd تنظیمات از مسیر جداگانهای وارد محیط فرایند میشوند. .gitignore هم راهنمای Git برای نادیدهگرفتن فایلهای مشخص است؛ مانع انتقال دستی، دسترسی HTTP یا افشای فایل قبلاً ثبتشده نمیشود. در File Explorer ممکن است نقطهٔ آغاز نام دیده نشود یا ساخت فایل دشوار باشد؛ آنها را از Explorer خود VS Code با نام دقیق بسازید. اگر یک فایل .txt اضافه شده باشد، نام را اصلاح و دوباره فهرست کنید. این دو بلوک بهترتیب محتوای کامل .env.example و .gitignore هستند:
PORT=3000node_modules/
.env
*.logآزمون محلی، چکیدهٔ فایل و انتقال کنترلشده
در PowerShell ویندوز و در پوشهٔ همین پروژه، npm.cmd run check و سپس npm.cmd test را اجرا کنید. دستور نخست باید بدون پیام خطای نحو پایان یابد؛ دستور دوم باید ۲۲ آزمون موفق و صفر شکست گزارش کند. اگر تعداد یا نتیجه فرق داشت، ابتدا نام و محتوای فایلها و نسخهٔ اجرا را بررسی کنید. برای اطمینان از انتقال سالم، از هفت فایل چکیدهٔ SHA-256 میسازیم. چکیده اثر انگشت محتواست؛ برابربودن آن در دو دستگاه نشان میدهد بایتها یکساناند، اما هویت نویسنده یا بیخطربودن برنامه را ثابت نمیکند. SHA256SUMS را با UTF-8 بدون BOM مینویسیم تا ابزار لینوکس آن را با قالب مورد انتظار بخواند. این فایل کمکی هشتم است و کد اجرایی تازهای اضافه نمیکند.
npm.cmd run check
if ($LASTEXITCODE -ne 0) { throw 'Syntax check failed; stop here.' }
npm.cmd test
if ($LASTEXITCODE -ne 0) { throw 'Tests failed; stop here.' }
$files = @('package.json','model.mjs','app.mjs','server.mjs','app.test.mjs','.env.example','.gitignore')
$lines = $files | ForEach-Object {
$hash = (Get-FileHash -LiteralPath $_ -Algorithm SHA256).Hash.ToLowerInvariant()
'{0} {1}' -f $hash, $_
}
[System.IO.File]::WriteAllLines((Join-Path $appDir 'SHA256SUMS'), [string[]]$lines, [System.Text.UTF8Encoding]::new($false))
Get-Content -LiteralPath .\SHA256SUMSاکنون در نشست SSH اوبونتو، با حساب مدیر عادی دارای sudo و بدون sudo برای پوشهٔ شخصی، بلوک کوتاه بعدی را اجرا کنید. node-upload محل ورود فایلهاست و releases محل اجرای نسخهٔ پذیرفتهشده خواهد بود؛ این دو را مخلوط نمیکنیم. mkdir دوم عمداً بدون -p نوشته شده تا اگر r1 وجود داشت، خطا بدهد و انتقال روی محتوای قبلی بیخبر انجام نشود. در آن صورت متوقف شوید و مشخص کنید پوشه از نوبت خودتان است یا باید آزمایش تازهای انتخاب کنید. داشتن پوشه در خانه به معنی مجوز سرویس برای خواندن آن نیست؛ در درس بعد فایلهای بررسیشده با مالکیت و مسیر مناسب نصب میشوند. فعلاً هیچ فایلی را در /opt یا /etc کپی نکنید.
mkdir -p "$HOME/node-upload"
mkdir -m 700 "$HOME/node-upload/r1"برای انتقال به PowerShell محلی برگردید. سه مقدار نشانی، نام کاربر و پورت SSH را مطابق یادداشت اتصال درس دوم عوض کنید؛ ۲۰۳.۰.۱۱۳.۱۰ نمونهٔ مستندات است و سرور قابل استفادهٔ شما نیست. مقصد node-upload/r1 نسبت به خانهٔ همان کاربر تفسیر میشود. گزینهٔ -P بزرگ در scp برای پورت است؛ آن را با -p کوچک در ssh جابهجا نکنید. هر هشت نام صریح فرستاده میشود تا فایلهای نقطهدار جا نمانند. از کپی node_modules ویندوز به لینوکس استفاده نمیکنیم. اگر درخواست اثر انگشت ناآشنا، خطای احراز هویت یا permission denied دیدید، همان لایه را طبق درس SSH بررسی کنید؛ برای عبور از آن، بررسی میزبان را خاموش یا مقصد را به پوشهٔ عمومی تبدیل نکنید.
$serverHost = '203.0.113.10'
$serverUser = 'ubuntu'
$sshPort = 22
scp.exe -P $sshPort .\package.json .\model.mjs .\app.mjs .\server.mjs .\app.test.mjs .\.env.example .\.gitignore .\SHA256SUMS "${serverUser}@${serverHost}:node-upload/r1/"
if ($LASTEXITCODE -ne 0) { throw 'Transfer failed; inspect the error before continuing.' }دوباره در SSH اوبونتو، وارد $HOME/node-upload/r1 شوید، ls -la را برای دیدن فایلهای نقطهدار بزنید و sha256sum -c SHA256SUMS را اجرا کنید. انتظار، هفت پیام OK است. اگر یک فایل FAILED یا یافتنشدن فایل گزارش شد، در همینجا توقف کنید؛ هنوز این پوشه نسخهٔ قابل اجرا محسوب نمیشود. نام دقیق، پسوند اضافه، ذخیرهسازی UTF-8 و انتقال دوبارهٔ همان فایل را بررسی کنید. تمرین: اگر همهٔ هشها برابرند اما تست روی لینوکس شکست میخورد، آیا انتقال خراب است؟ پاسخ: الزاماً خیر؛ برابربودن بایتها یک شاهد مشخص است و باید تفاوت نسخهٔ Node، مجوز، مسیر و فرضهای وابسته به سیستم عامل را بررسی کرد. معیار تحویل، فهرست هشت فایل، نتیجهٔ آزمون محلی، هفت OK و توضیح محدودیت حافظهای برنامه است. اکنون برای نصب یک نسخهٔ مشخص در درس بعد آمادهاید.
منابع رسمی انگلیسی:
Node.js 24 — HTTP: https://nodejs.org/docs/latest-v24.x/api/http.html
Node.js — ECMAScript modules: https://nodejs.org/api/esm.html
Node.js 24 — Test runner: https://nodejs.org/docs/latest-v24.x/api/test.html
OpenSSH scp در Ubuntu 24.04: https://manpages.ubuntu.com/manpages/noble/man1/scp.1.html
PowerShell Get-FileHash: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-5.1
مرور انتقال امن فایل در درسآوند: https://www.darsavand.com/courses/linux-server-from-zero?lesson=8706f5a1-9ec7-400e-886f-f74780325e25
تألیف و مثالهای اختصاصی: تحریریه درسآوند. منابع رسمی انگلیسی برای مطالعهٔ تکمیلی معرفی شدهاند.
متن درسهای عمومی را بدون ورود بخوانید.