সরাসরি পর্বে যান
পর্ব ৫৫ · ৫৯-এর মধ্যে · মডিউল ৭

ক্যাপস্টোন — “দোকানঘর”: একটি full-stack অ্যাপ বানানো ও deploy

JavaScript Capstone — A Full-Stack Shop App with Vite, Express, PostgreSQL, SSE, Tests, CI and Deployment

Taught in Bangla, this capstone builds and ships a full-stack shop app milestone by milestone: requirements and architecture with a shared data contract, a Vite front end on mock data, an in-memory Express API, then PostgreSQL, login, validation and security, live order status with SSE, tests, linting and CI, deployment to the internet, and a launch checklist with logging and monitoring.

এই পর্বে যা শিখবেন

  • চাহিদাকে user story-তে লেখা, পরিধি ঠিক করা আর স্থাপত্য ও ডেটা মডেল আঁকা
  • একটি অর্ডার কীভাবে UI থেকে middleware, যাচাই, service, ডেটাবেস transaction আর SSE হয়ে আবার UI-তে ফেরে
  • সাতটি মাইলস্টোনে প্রজেক্ট ভাগ করা — প্রতিটির মূল কোড, সিদ্ধান্ত আর “শেষ হয়েছে” যাচাই
  • টেস্টের পিরামিড, GitHub Actions-এ CI, আর static host + API host বা VPS-এ deploy
  • launch-এর পরের কাজ — log, error, backup, হালনাগাদ — আর portfolio ও ইন্টারভিউতে প্রজেক্ট উপস্থাপন

১ · চাহিদা — “দোকানঘর” কী করবে, কী করবে না

চুয়ান্নটি পর্বে আপনি ভাষা শিখেছেন, ব্রাউজার শিখেছেন, সার্ভার শিখেছেন। পর্ব ২১-এ বানিয়েছিলেন “হিসাব খাতা” — একটি পাতায়, একজন ব্যবহারকারীর জন্য, memory-তে। এবার সেই ধারণাকে বড় করব: অনেক ব্যবহারকারী, একটি সার্ভার, একটি ডেটাবেস, আর ইন্টারনেটে একটি আসল ঠিকানা। এমন অ্যাপকে বলা হয় full-stack অ্যাপ — ব্রাউজারের পাতা থেকে ডেটাবেসের টেবিল পর্যন্ত পুরো স্তূপ আপনার হাতে।

গল্পটি এরকম। মিরপুরে নাসরিন আপার একটি মুদি দোকান আছে। পাড়ার ক্রেতারা ফোনে অর্ডার দেন, তিনি খাতায় লেখেন, আর প্রায়ই গোলমাল হয় — শেষ প্যাকেট ডাল দুজনকে কথা দিয়ে ফেলেন। তিনি চান একটি ছোট ওয়েব অ্যাপ: ক্রেতা পণ্য দেখে কার্টে রাখবেন, অর্ডার দেবেন, আর অর্ডার “প্যাক হয়েছে”, “রওনা হয়েছে” — এই অবস্থা নিজের ফোনে লাইভ দেখবেন। আমরা বানাব দোকানঘর.

User story — ব্যবহারকারীর চোখে চাহিদা

চাহিদা লেখার একটি জনপ্রিয় ঢং হলো user story: “একজন <কে> হিসেবে আমি <কী> চাই, যাতে <কেন>”. প্রতিটি story-র সাথে থাকে কিছু শর্ত, যা পূরণ হলে story শেষ:

#User storyশেষ ধরব যখন…কোন পর্বের টুল
১ক্রেতা হিসেবে আমি পণ্যের তালিকা দাম ও স্টকসহ দেখতে চাইস্টক শূন্য হলে “স্টক নেই” দেখায়, কার্টে নেওয়া যায় নাfetch (পর্ব ৩৫), DOM (পর্ব ৩৭)
২ক্রেতা হিসেবে কার্টে পণ্য যোগ-বাদ দিয়ে মোট দাম দেখতে চাইমোট বাংলা টাকায়, পয়সার হিসাব নির্ভুলIntl (পর্ব ২০), event (পর্ব ৩৮)
৩ক্রেতা হিসেবে নাম-মোবাইল দিয়ে অর্ডার দিতে চাইভুল নম্বরে স্পষ্ট বার্তা; স্টকের বেশি চাইলে অর্ডার হয় নাform (পর্ব ৩৯), Express (পর্ব ৫০)
৪ক্রেতা হিসেবে আমার অর্ডারের অবস্থা পাতা reload না করে দেখতে চাইদোকানি অবস্থা বদলালে কয়েক সেকেন্ডে ক্রেতার পাতায় আসেSSE (পর্ব ৫১)
৫দোকানি হিসেবে লগইন করে অর্ডারের অবস্থা বদলাতে চাইলগইন ছাড়া কেউ অবস্থা বদলাতে পারে না; ভুল ধাপ আটকায়cookie (পর্ব ৪০), নিরাপত্তা (পর্ব ৪৩)
৬দোকানি হিসেবে চাই কোনো পণ্য স্টকের বেশি বিক্রি না হোকএকসাথে দুজন শেষ পিস চাইলেও একজনই পানডেটাবেস transaction (পর্ব ৫২)

কী বানাব না — পরিধির সীমা

প্রথম সংস্করণে থাকবে না: অনলাইন পেমেন্ট (দোকানি টাকা হাতে নেবেন, অবস্থা “paid” করবেন নিজে), ছবি আপলোড, একাধিক দোকান, ক্রেতার নিজস্ব অ্যাকাউন্ট (ক্রেতা অর্ডার দেন নাম-মোবাইলে, লগইন শুধু দোকানির)। এই ছোট, কিন্তু শুরু থেকে শেষ পর্যন্ত কাজ করে এমন সংস্করণই আমাদের MVP. পেমেন্ট আর বাকিগুলো আছে শেষে “বাড়ানোর ধারণা”-য়।

মূল ধারণা

ক্যাপস্টোনের আসল শিক্ষা কোনো নতুন library নয় — জোড়া লাগানো আর শেষ করা। আলাদা পর্বের জিনিস একসাথে চালাতে গেলে নতুন প্রশ্ন আসে — cookie কোন domain-এ যাবে, টেস্টে ডেটাবেস কোথা থেকে আসবে; এই উত্তর খোঁজাই ক্যাপস্টোন।

২ · স্থাপত্য আর ডেটা মডেল — তিনটি অংশ, একটি চুক্তি

দোকানঘরের তিনটি চলমান অংশ। ব্রাউজারের অ্যাপ — HTML, CSS আর JavaScript module, যা Vite দিয়ে বানানো (build) হয়ে কিছু static ফাইল হয়ে যায়। API সার্ভার — Node.js-এ Express, যা JSON-এ কথা বলে। ডেটাবেস — PostgreSQL; নিজের কম্পিউটারে চাইলে সহজের জন্য SQLite. এদের মাঝে দুটি পথ: সাধারণ অনুরোধ-উত্তর (fetch → REST API), আর সার্ভার থেকে ব্রাউজারে একমুখী লাইভ বার্তা (SSE).

ডেটা মডেল — চারটি টেবিল

টেবিলমূল কলামসিদ্ধান্ত ও কারণ
productsid, title, price_paisa, stockদাম পূর্ণসংখ্যা পয়সায় (পর্ব ১০, ২১); CHECK (stock >= 0) — ডেটাবেস নিজেই ঋণাত্মক স্টক আটকায়
ordersid, customer_name, phone, status, total_paisa, created_atstatus শুধু ছয়টি নির্দিষ্ট মানের একটি; মোট দাম অর্ডারের মুহূর্তে লিখে রাখা
order_itemsorder_id, product_id, qty, unit_price_paisaদাম কপি করে রাখা — পরে দাম বদলালেও পুরোনো অর্ডার অক্ষত
usersid, email, password_hashপাসওয়ার্ড কখনো সরাসরি নয়, শুধু hash

API চুক্তি — front end আর back end-এর মাঝের দলিল

Method ও পথকাজসফলব্যর্থ
GET /api/productsপণ্যের তালিকা200—
POST /api/ordersঅর্ডার দেওয়া201 + অর্ডার422 ভুল ইনপুট, 409 স্টক নেই
GET /api/orders/:id/liveঅবস্থার লাইভ stream (SSE)200 text/event-stream404
POST /api/auth/loginদোকানির লগইন204 + cookie401
PATCH /api/orders/:id/statusঅবস্থা বদলানো (শুধু লগইন করা দোকানি)200401, 409 ভুল ধাপ

চুক্তিটি আগে লিখলে front end আর back end আলাদাভাবে বানানো যায়। ভুল ইনপুটে 422, আর ইনপুট ঠিক কিন্তু বর্তমান অবস্থার সাথে সংঘাত (স্টক নেই, ভুল ধাপ) হলে 409 Conflict — সঠিক status code থাকলে UI প্রতিটি ভুলে আলাদা বার্তা দিতে পারে।

একটি অর্ডারের পুরো যাত্রা

রাফি কার্টে দুই প্যাকেট চাল রেখে “অর্ডার দিন” চাপলেন। পুরো সিস্টেমের ভেতর দিয়ে অনুরোধটি কীভাবে যায় আর ফেরে, ধাপে ধাপে দেখুন:

  1. UIcheckout()যাচাই, বোতাম বন্ধ
  2. Middlewarejson · sessionপ্রতিটি অনুরোধে
  3. যাচাই422?সার্ভারে আবার
  4. ServiceplaceOrderব্যবসার নিয়ম
  5. ডেটাবেসBEGIN…COMMITসব বা কিছুই না
  6. SSEevent: statusলাইভ বার্তা
ধাপ ১ · ব্রাউজারআগে ব্রাউজারেই ধরা
validateCustomer({ fullName, phone })  → null
$('#pay').disabled = true;   // দুবার চাপা ঠেকাতে
msg: 'পাঠানো হচ্ছে…'
ধাপ ২ · নেটওয়ার্কfetch + cookie
POST /api/orders   HTTP/1.1
Content-Type: application/json
Cookie: sid=… (দোকানির হলে; ক্রেতার জন্য লাগে না)
{"customer":{…},"items":[{"productId":1,"qty":2}]}
ধাপ ৩ · middleware-এর সারিএকটির পর একটি
helmet()             // নিরাপত্তার header
express.json({ limit: '20kb' })   // body → req.body
session              // cookie → req.user
rateLimit            // এক IP থেকে মিনিটে সীমিত
next() → route handler
ধাপ ৪ · সার্ভারে যাচাইব্রাউজারকে বিশ্বাস নয়
items কি array? খালি না?  qty কি ১–৫০-এর integer?
phone কি /^01[3-9]\d{8}$/ মেলে?
ভুল হলে → 422 { error: 'qty ভুল' }, এখানেই থামে
ধাপ ৫ · serviceHTTP চেনে না
placeOrder(customer, items)
  → প্রতিটি পণ্যের দাম ডেটাবেস থেকে (ব্রাউজারের দাম নয়!)
  → মোট হিসাব, তারপর repository-কে ডাকা
স্টক কম → throw new AppError(409, …)
ধাপ ৬ · transactionমাঝপথে ভুল = সব বাতিল
BEGIN;
UPDATE products SET stock = stock - 2 WHERE id = 1 AND stock >= 2;  -- ১টি সারি?
INSERT INTO orders … ; INSERT INTO order_items … ;
COMMIT;   -- কোনো ধাপ ব্যর্থ হলে ROLLBACK
ধাপ ৭ · উত্তর আর ঘোষণা201, তারপর SSE
res.status(201).json(order)
bus.emit('status', { id: 7, status: 'pending' })
data: {"id":7,"status":"pending"}  → দোকানির ড্যাশবোর্ডে
ধাপ ৮ · আবার ব্রাউজারপাতা = f(state)
cart = {};  render();
msg: 'অর্ডার #7 নেওয়া হয়েছে'      // aria-live পড়ে শোনায়
new EventSource('/api/orders/7/live')
… পরে: 'packed' এলে badge নিজে থেকে বদলায়
  1. ব্রাউজারে প্রথম যাচাই। নাম আর মোবাইল নম্বর ঠিক আছে কি না, ব্রাউজারেই দেখা হয়। বোতাম বন্ধ করা হয়, যাতে দুবার চাপলে দুটি অর্ডার না হয় (double submit).
  2. fetch অনুরোধ পাঠায়। JSON body আর Content-Type header নিয়ে POST. দোকানি লগইন থাকলে ব্রাউজার নিজে থেকেই session cookie জুড়ে দেয়।
  3. Middleware-এর সারি। Express-এ প্রতিটি অনুরোধ route-এ পৌঁছানোর আগে কয়েকটি middleware ফাংশনের ভেতর দিয়ে যায় — নিরাপত্তার header বসানো, body পড়া, session খোঁজা, অনুরোধের হার মাপা। প্রতিটি হয় next() ডাকে, নয়তো নিজেই উত্তর দেয়।
  4. সার্ভারে আবার যাচাই। ব্রাউজারের যাচাই শুধু সুবিধা; যে কেউ DevTools বা curl দিয়ে সরাসরি API-তে যা খুশি পাঠাতে পারে। তাই server-side validation বাধ্যতামূলক (পর্ব ৩৯)। ভুল হলে 422, আর অনুরোধ এখানেই শেষ।
  5. Service — ব্যবসার নিয়ম। দাম নেওয়া হয় ডেটাবেস থেকে, ব্রাউজারের পাঠানো দাম থেকে নয় — নইলে কেউ চালের দাম ১ টাকা লিখে পাঠাতে পারত (parameter tampering).
  6. ডেটাবেস transaction. স্টক কমানো, অর্ডার লেখা, অর্ডারের পণ্যগুলো লেখা — তিনটি কাজ একটি transaction-এ। শর্তসহ UPDATE একটি সারিও না বদলালে স্টক নেই, তখন ROLLBACK — যেন কিছুই হয়নি।
  7. উত্তর, তারপর ঘোষণা। COMMIT সফল হলে 201 আর নতুন অর্ডার। একই সাথে সার্ভারের ভেতরের একটি ঘোষণা-ব্যবস্থা (bus) জানায় “নতুন অর্ডার”, আর যারা SSE-তে শুনছে — যেমন দোকানির ড্যাশবোর্ড — তাদের কাছে বার্তা চলে যায় (পর্ব ৫১)।
  8. UI আবার আঁকে। কার্ট খালি, সফলতার বার্তা, আর এই অর্ডারের জন্য একটি EventSource খোলা। বার্তার জায়গাটি aria-live, তাই screen reader ব্যবহারকারীও খবরটি শোনেন।
C/C++/Python/Java জানলে

আমাদের Java ক্যাপস্টোনেও একটি দোকানের অর্ডার-ব্যবস্থা আছে — সেখানে core, port আর adapter ভাগ, JDBC transaction, Spring REST আর শেষ পিস নিয়ে concurrency-র আলোচনা; Python ক্যাপস্টোনে চার স্তরের (CLI → service → domain → storage) একটি terminal অ্যাপ। নকশার নীতি একই: ব্যবসার নিয়ম মাঝখানে, বাইরের প্রযুক্তি চারপাশে। JavaScript-এর বিশেষত্ব হলো একই ভাষা ব্রাউজার আর সার্ভার দুই দিকে চলে — যাচাইয়ের একটি ফাংশন দুই জায়গাতেই ব্যবহার করা যায়। তুলনা করতে পড়ুন Java-র ক্যাপস্টোন আর Python-এর ক্যাপস্টোন.

৩ · মাইলস্টোন ১–৩ — নকল ডেটার UI, memory-তে API, তারপর ডেটাবেস

পুরো অ্যাপ একবারে নয়, বানাব সাতটি মাইলস্টোনে। প্রতিটি শেষে অ্যাপটি চলে, শুধু আগের চেয়ে একটু বেশি পারে। প্রতিটির শেষে একটি “শেষ হয়েছে যখন…” তালিকা আছে; সেটি পূরণ না হলে পরের ধাপে যাবেন না।

প্রজেক্টের কাঠামো

একটি git repository, ভেতরে দুটি অংশ — web আর api. npm-এর workspaces সুবিধা দিয়ে দুটিকে একসাথে চালানো যায় (npm আর package.json-এর মূল কথা পর্ব ৪৬-এ):

dokanghor/
├── package.json            # workspaces: ["web", "api"]
├── .github/workflows/ci.yml
├── web/                    # Vite front end
│   ├── index.html
│   ├── src/  main.js  api.js  cart.js  render.js  styles.css
│   └── tests/  cart.test.js
├── api/                    # Express API
│   ├── src/  app.js  server.js  routes/  services/  repo/  bus.js
│   ├── migrations/  001_init.sql  002_users.sql
│   ├── tests/  orders.test.js
│   └── .env.example        # কোন কোন environment variable লাগে — মান ছাড়া
├── e2e/  checkout.spec.js  # Playwright
└── README.md
{
  "type": "module",
  "scripts": {
    "dev": "node --watch --env-file=.env src/server.js",
    "start": "node src/server.js",
    "migrate": "node scripts/migrate.js",
    "test": "vitest run",
    "lint": "eslint . && prettier --check ."
  }
}

"type": "module" মানে সব ফাইল ES module (পর্ব ৪৫)। --watch ফাইল বদলালে সার্ভার নিজে থেকে আবার চালু করে, আর --env-file (Node 20.6 থেকে) .env ফাইল থেকে environment variable পড়ে — আলাদা package ছাড়াই।

মাইলস্টোন ১ — নকল ডেটায় পুরো UI

প্রথমে সার্ভার ছাড়াই পুরো UI: পণ্যের তালিকা, কার্ট, চেকআউট ফর্ম, বার্তা। ডেটা আসে একটি নকল api.js থেকে, যার ফাংশনগুলোর আকার হুবহু আসল API-র মতো — async, একই নাম, একই উত্তর। পরে শুধু এই ফাইলের ভেতরটা বদলাবে, বাকি UI নয়। কার্টের হিসাব পর্ব ২১-এর মতো pure function-এ (functional core, imperative shell). এই মাইলস্টোনের চালানো যায় এমন রূপটি অংশ ৫-এ আছে।

মাইলস্টোন ২ — Express API, ডেটা memory-তে

এবার আসল সার্ভার, তবে ডেটা এখনো একটি Map-এ। একসাথে দুটি নতুন জিনিস (HTTP আর SQL) ভাঙলে কোনটি ভাঙল বোঝা কঠিন। Route-গুলো পাতলা — অনুরোধ পড়ে, service ডাকে, উত্তর দেয়:

import express from 'express';
import helmet from 'helmet';
import { ordersRouter } from './routes/orders.js';

export function createApp({ service }) {           // dependency বাইরে থেকে — টেস্টে সহজ
  const app = express();
  app.use(helmet());                                 // নিরাপত্তার header
  app.use(express.json({ limit: '20kb' }));          // বড় body আটকায়
  app.get('/healthz', (req, res) => res.json({ ok: true }));
  app.use('/api/orders', ordersRouter(service));
  app.use((err, req, res, next) => {                 // এক জায়গায় সব error
    if (!err.status) req.log?.error(err);            // অপ্রত্যাশিত হলে log
    res.status(err.status ?? 500).json({ error: err.status ? err.message : 'সার্ভারে সমস্যা' });
  });
  return app;
}

তিনটি সিদ্ধান্ত লক্ষ করুন। এক: createApp সার্ভার চালু করে না, শুধু app বানায়; listen হয় আলাদা server.js-এ। তাই টেস্টে port না খুলেই app ব্যবহার করা যায়। দুই: service বাইরে থেকে দেওয়া হয় — M2-তে memory-র service, M3-এ ডেটাবেসের; app জানেই না। তিন: চার argument-এর শেষ middleware-টি Express-এর error handler. জানা ভুল (err.status আছে, যেমন 409) হলে বার্তা যায় ব্যবহারকারীর কাছে; অজানা ভুল হলে শুধু “সার্ভারে সমস্যা” — ভেতরের stack trace বাইরে ফাঁস হয় না (পর্ব ২৯-এর operational error বনাম programmer error). Express 5 থেকে async route-এর rejected promise নিজে থেকেই এই handler-এ আসে; Express 4-এ সেটি হাতে next(err) দিয়ে পাঠাতে হতো।

মাইলস্টোন ৩ — PostgreSQL আর migration

ডেটাবেসের কাঠামো কখনো হাতে বানাবেন না; লিখে রাখুন নম্বর দেওয়া SQL ফাইলে। প্রতিটি ফাইল একবারই চলে, আর কোনটি চলেছে তা ডেটাবেসের একটি টেবিলে লেখা থাকে। একে বলে migration — ডেটাবেসের জন্য version control:

CREATE TABLE products (
  id          SERIAL PRIMARY KEY,
  title       TEXT    NOT NULL,
  price_paisa INTEGER NOT NULL CHECK (price_paisa > 0),
  stock       INTEGER NOT NULL CHECK (stock >= 0)
);
CREATE TABLE orders (
  id            SERIAL PRIMARY KEY,
  customer_name TEXT    NOT NULL,
  phone         TEXT    NOT NULL,
  status        TEXT    NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending','paid','packed','shipped','delivered','cancelled')),
  total_paisa   INTEGER NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
  order_id         INTEGER NOT NULL REFERENCES orders(id),
  product_id       INTEGER NOT NULL REFERENCES products(id),
  qty              INTEGER NOT NULL CHECK (qty > 0),
  unit_price_paisa INTEGER NOT NULL,
  PRIMARY KEY (order_id, product_id)
);

এবার অর্ডার রাখার আসল কোড — একটি transaction, আর প্রতিটি মান parameterized query-তে ($1, $2), কখনো string জোড়া দিয়ে নয়; তাহলে SQL injection-এর পথ বন্ধ (পর্ব ৫২):

export async function insertOrder(pool, { customer, lines, total }) {
  const db = await pool.connect();                  // pool থেকে একটি সংযোগ ধার
  try {
    await db.query('BEGIN');
    for (const l of lines) {
      const r = await db.query(
        'UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1', [l.qty, l.productId]);
      if (r.rowCount !== 1) throw new AppError(409, `পণ্য ${l.productId}: স্টক নেই`);
    }
    const { rows: [order] } = await db.query(
      'INSERT INTO orders (customer_name, phone, total_paisa) VALUES ($1, $2, $3) RETURNING *',
      [customer.fullName, customer.phone, total]);
    for (const l of lines) await db.query(
      'INSERT INTO order_items VALUES ($1, $2, $3, $4)', [order.id, l.productId, l.qty, l.price]);
    await db.query('COMMIT');
    return order;
  } catch (e) {
    await db.query('ROLLBACK');                      // মাঝপথের সব বদল বাতিল
    throw e;
  } finally {
    db.release();                                    // সংযোগ pool-এ ফেরত — না দিলে leak
  }
}

সবচেয়ে গুরুত্বপূর্ণ লাইনটি হলো শর্তসহ UPDATE. “আগে স্টক পড়ি, তারপর যথেষ্ট থাকলে কমাই” — দুই ধাপে করলে দুজন ক্রেতা একই মুহূর্তে শেষ প্যাকেটটি পড়বেন, দুজনেই দেখবেন “১টি আছে”, দুজনেরই অর্ডার হবে: একটি race condition. এক লাইনের শর্তসহ UPDATE-এ ডেটাবেস নিজে সারিটি তালা দিয়ে পরীক্ষা আর বদল একসাথে করে; দ্বিতীয়জনের rowCount হয় ০. finally-তে release() না দিলে কয়েকশো অনুরোধ পরে connection pool ফাঁকা হয়ে যাবে আর সার্ভার চুপচাপ ঝুলে থাকবে।

৪ · মাইলস্টোন ৪–৫ — লগইন, যাচাই, নিরাপত্তা আর লাইভ অবস্থা

মাইলস্টোন ৪ — হালকা লগইন: HttpOnly session cookie

শুধু দোকানি লগইন করবেন। সবচেয়ে সরল আর নিরাপদ পথ হলো সার্ভারে রাখা session: লগইন সফল হলে সার্ভার একটি লম্বা এলোমেলো id বানায়, ডেটাবেসে লিখে রাখে “এই id = এই দোকানি”, আর id-টি পাঠায় একটি cookie-তে। Cookie-র তিনটি flag বাধ্যতামূলক — HttpOnly (JavaScript পড়তে পারে না, তাই XSS হলেও চুরি কঠিন), Secure (শুধু HTTPS-এ যায়), আর SameSite=Lax (অন্য সাইট থেকে আসা POST-এ যায় না, ফলে CSRF অনেকটা আটকায়)।

import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';
const scryptAsync = promisify(scrypt);

async function verifyPassword(password, stored) {     // stored = 'salt:hash'
  const [salt, hash] = stored.split(':');
  const test = await scryptAsync(password, salt, 64);
  return timingSafeEqual(test, Buffer.from(hash, 'hex'));   // সময় দেখে আন্দাজ ঠেকায়
}

router.post('/login', loginLimiter, async (req, res) => {
  const user = await repo.findUserByEmail(req.body.email);
  if (!user || !(await verifyPassword(req.body.password, user.password_hash)))
    return res.status(401).json({ error: 'ইমেইল বা পাসওয়ার্ড ভুল' });  // কোনটি ভুল, বলি না
  const sid = randomBytes(32).toString('base64url');
  await repo.createSession(sid, user.id);              // মেয়াদসহ
  res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 8 * 3600e3 });
  res.status(204).end();
});

পাসওয়ার্ড কখনো সরাসরি রাখা হয় না — রাখা হয় ইচ্ছা করে ধীর একটি hash. Node-এর নিজস্ব crypto.scrypt এমন একটি password hashing ফাংশন; প্রতিটি ব্যবহারকারীর আলাদা salt থাকায় একই পাসওয়ার্ডের hash-ও আলাদা। লগইন route-এ আলাদা rate limit পাসওয়ার্ড আন্দাজের আক্রমণ ধীর করে।

Front end আর API আলাদা domain-এ থাকলে cookie-র ফাঁদ। ধরুন front end dokanghor.netlify.app-এ আর API dokanghor-api.onrender.com-এ। ব্রাউজারের চোখে এরা আলাদা site, তাই আমাদের cookie হবে third-party cookie — যা অনেক ব্রাউজার আটকায়, আর SameSite=Lax তো পাঠাবেই না। দুটি পরিষ্কার সমাধান: (১) নিজের domain নিয়ে dokanghor.com আর api.dokanghor.com — একই site, শুধু CORS-এ credentials চালু করতে হয়; (২) static host-এর rewrite/proxy সুবিধায় /api/* অনুরোধ API-তে পাঠানো — ব্রাউজারের চোখে সব একই origin, CORS-ই লাগে না। এই সিদ্ধান্ত deploy-এর আগেই নিন।

এই মাইলস্টোনেই বাকি ভিত: helmet দিয়ে security header আর CSP, ব্যবহারকারীর লেখা শুধু textContent-এ (XSS), আর প্রতিটি route-এর ইনপুট schema দিয়ে যাচাই।

অর্ডারের অবস্থা — একটি state machine

অবস্থা বদলানোর route সবচেয়ে বিপজ্জনক — একটি ভুল ক্লিকে “delivered” অর্ডার আবার “pending” হয়ে যেতে পারত। তাই অবস্থা যেকোনো মানে বসানো যায় না; আছে একটি টেবিল, কোন অবস্থা থেকে কোন অবস্থায় যাওয়া যায়। এটিই state machine. ধাপে ধাপে দেখুন কোন চেষ্টা সফল, কোনটি আটকায়:

1const NEXT = {2  pending:   ['paid', 'cancelled'],3  paid:      ['packed', 'cancelled'],4  packed:    ['shipped'],5  shipped:   ['delivered'],6  delivered: [], cancelled: [] };7if (!NEXT[o.status].includes(to)) → 409
ভ্যারিয়েবলের মান
o.status
to
ফল
  1. জন্ম: pending. নতুন অর্ডার সবসময় pending অবস্থায় জন্মায়; ঠিক করে ডেটাবেসের DEFAULT, ব্রাউজার নয়।
  2. প্রতিটি অবস্থার অনুমোদিত তালিকা। pending থেকে যাওয়া যায় শুধু paid বা cancelled-এ। নিয়মটি if-else-এর জঙ্গলে নয়, একটি সাধারণ object-এ — এক নজরে পড়া যায়, টেস্টে প্রতিটি সারি পরীক্ষা করা যায় (পর্ব ১১-এর lookup table).
  3. ধাপ ডিঙানো যায় না। টাকা নেওয়ার আগেই “shipped”? তালিকায় নেই, তাই 409.
  4. pending → paid. দোকানি টাকা পেয়ে অবস্থা বদলালেন। Bus-এর ঘোষণায় ক্রেতার পাতায় SSE দিয়ে “paid” পৌঁছায়।
  5. paid → packed. প্যাকিং শেষ। এখান থেকে পরের অনুমোদিত ধাপ শুধু shipped.
  6. প্যাক হওয়ার পর বাতিল নেই। এটি কোডের নয়, ব্যবসার সিদ্ধান্ত — নাসরিন আপা বলেছেন, প্যাক হলে বাতিল মানে “ফেরত”-এর আলাদা প্রক্রিয়া। Code শুধু সিদ্ধান্তটি কড়াভাবে মানায়। বাতিল হলে স্টক ফেরত দেওয়ার কাজটি অনুশীলন ১-এ।
  7. packed → shipped. রওনা হলো, ক্রেতা লাইভ দেখছেন।
  8. delivered — শেষ অবস্থা। খালি তালিকা মানে এখান থেকে আর কোথাও যাওয়া নেই; যেকোনো চেষ্টা 409. cancelled-ও তেমনি শেষ অবস্থা।

মাইলস্টোন ৫ — SSE-তে লাইভ অবস্থা

অবস্থা বদলালে ক্রেতাকে জানাতে হবে। প্রতি পাঁচ সেকেন্ডে জিজ্ঞেস করা (polling) অপচয়; দুই দিকে কথা বলার WebSocket এখানে বাড়তি। আমাদের দরকার শুধু সার্ভার → ব্রাউজার, তাই SSE (পর্ব ৫১) — সাধারণ HTTP, ব্রাউজারের EventSource সংযোগ ছিঁড়লে নিজেই আবার জোড়ে:

import { bus } from '../bus.js';                    // node:events-এর EventEmitter

router.get('/:id/live', async (req, res) => {
  const id = Number(req.params.id);
  const order = await service.getOrder(id);          // না থাকলে 404 ছোড়ে
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
  const send = (o) => res.write(`id: ${Date.now()}\nevent: status\ndata: ${JSON.stringify(o)}\n\n`);
  send({ id, status: order.status });                // শুরুতেই বর্তমান অবস্থা
  const onStatus = (o) => { if (o.id === id) send(o); };
  bus.on('status', onStatus);
  const ping = setInterval(() => res.write(': ping\n\n'), 25_000);   // proxy যেন সংযোগ না কাটে
  req.on('close', () => { bus.off('status', onStatus); clearInterval(ping); });   // leak ঠেকানো
});
export function watchOrder(id, onStatus) {
  const es = new EventSource(`/api/orders/${id}/live`);
  es.addEventListener('status', (e) => onStatus(JSON.parse(e.data).status));
  return () => es.close();                           // পাতা ছাড়লে বন্ধ করুন
}

সবচেয়ে সহজে ভুল হয় req.on('close')-এর লাইনটি। এটি না থাকলে প্রতিটি বন্ধ হয়ে যাওয়া ট্যাবের listener bus-এ থেকে যায় — কয়েক দিনে হাজারো মৃত listener, বাড়তে থাকা memory: পর্ব ৫৩-এর ধ্রুপদি memory leak. আরেকটি সীমা মনে রাখুন: এই bus একটি process-এর ভেতরে। সার্ভার দুটি process-এ চালালে এক process-এর ঘোষণা অন্যটির ক্রেতা পাবেন না; তখন PostgreSQL-এর LISTEN/NOTIFY বা Redis-এর মতো একটি ভাগাভাগি করা ঘোষণা-ব্যবস্থা লাগে। ছোট দোকানে একটি process যথেষ্ট; সীমাটি README-তে লিখুন।

৫ · দুটি core — এই পাতাতেই চালিয়ে দেখুন

Express, PostgreSQL বা Vite এই পাতার runner-এ নেই — Node-এর runner-এ কোনো npm package বসানো যায় না, ব্রাউজারের runner-এ নেটওয়ার্ক নেই। কিন্তু অ্যাপের হৃদয় package ছাড়াই লেখা যায়। নিচের প্রথম ব্লকটি Node.js 20-এ চলে: node:http দিয়ে একটি ছোট সার্ভার, memory-তে একটি repository, আর সেই একই service — validation, স্টক পরীক্ষা, transaction-এর মতো rollback আর অবস্থার নিয়ম। তারপর প্রোগ্রামটি fetch দিয়ে নিজের সার্ভারকেই অনুরোধ পাঠিয়ে সব নিয়ম পরীক্ষা করে।

const http = require("node:http");
// ===== repository: in-memory "ডেটাবেস" (আসল অ্যাপে PostgreSQL) =====
const db = {
  products: new Map([[1, { id: 1, name: "চাল ৫ কেজি", price: 42000, stock: 3 }],
                     [2, { id: 2, name: "ডাল ১ কেজি", price: 14000, stock: 1 }]]),
  orders: new Map(), nextId: 1,
};
function transaction(work) {                    // সব হবে, নয়তো কিছুই না
  const snapshot = structuredClone(db);
  try { return work(); } catch (e) { Object.assign(db, snapshot); throw e; }
}
// ===== service: ব্যবসার নিয়ম — HTTP চেনে না =====
class AppError extends Error { constructor(status, msg) { super(msg); this.status = status; } }
const NEXT = { pending: ["paid", "cancelled"], paid: ["packed", "cancelled"],
               packed: ["shipped"], shipped: ["delivered"], delivered: [], cancelled: [] };
function placeOrder(items) {
  if (!Array.isArray(items) || items.length === 0) throw new AppError(422, "কার্ট খালি");
  return transaction(() => {
    let total = 0;
    for (const { productId, qty } of items) {
      if (!Number.isInteger(qty) || qty < 1) throw new AppError(422, "qty ভুল");
      const p = db.products.get(productId);
      if (!p) throw new AppError(404, `পণ্য ${productId} নেই`);
      if (p.stock < qty) throw new AppError(409, `${p.name}: স্টক মাত্র ${p.stock}`);
      p.stock -= qty;                           // কমানো শুরু — মাঝপথে ভুল হলে rollback
      total += p.price * qty;
    }
    const order = { id: db.nextId++, items, total, status: "pending" };
    db.orders.set(order.id, order);
    return order;
  });
}
function changeStatus(id, to) {
  const o = db.orders.get(id);
  if (!o) throw new AppError(404, "অর্ডার নেই");
  if (!NEXT[o.status].includes(to)) throw new AppError(409, `${o.status} → ${to} চলবে না`);
  o.status = to;
  return o;
}
// ===== HTTP স্তর: অনুরোধ পড়ে service-কে ডাকে =====
const send = (res, code, data) =>
  res.writeHead(code, { "Content-Type": "application/json", Connection: "close" }).end(JSON.stringify(data));
const server = http.createServer(async (req, res) => {
  try {
    let body = "";
    for await (const chunk of req) body += chunk;
    const data = body ? JSON.parse(body) : {};
    const m = req.url.match(/^\/api\/orders\/(\d+)\/status$/);
    if (req.method === "GET" && req.url === "/api/products") return send(res, 200, [...db.products.values()]);
    if (req.method === "POST" && req.url === "/api/orders") return send(res, 201, placeOrder(data.items));
    if (req.method === "PATCH" && m) return send(res, 200, changeStatus(Number(m[1]), data.status));
    send(res, 404, { error: "এমন পথ নেই" });
  } catch (e) { send(res, e.status ?? 400, { error: e.message }); }
});
// ===== client: নিজের সার্ভারকেই fetch দিয়ে পরীক্ষা =====
server.listen(0, "127.0.0.1", async () => {
  const base = `http://127.0.0.1:${server.address().port}/api`;
  const call = async (method, path, body) => {
    const r = await fetch(base + path, { method, headers: { "Content-Type": "application/json" },
      body: body && JSON.stringify(body) });
    return [r.status, await r.json()];
  };
  const show = ([code, d]) => console.log(code, d.error ?? `#${d.id} ${d.status} ${d.total}`);
  show(await call("POST", "/orders", { items: [{ productId: 1, qty: 2 }] }));
  show(await call("POST", "/orders", { items: [{ productId: 1, qty: 1 }, { productId: 2, qty: 5 }] }));
  const [, list] = await call("GET", "/products");
  console.log("স্টক:", list.map((p) => `${p.name}=${p.stock}`).join(", "));
  show(await call("POST", "/orders", { items: [] }));
  show(await call("PATCH", "/orders/1/status", { status: "shipped" }));
  for (const s of ["paid", "packed", "shipped", "delivered"]) show(await call("PATCH", "/orders/1/status", { status: s }));
  show(await call("PATCH", "/orders/1/status", { status: "cancelled" }));
  server.close();
});

প্রথম লাইন 201 #1 pending 84000 — দুই প্যাকেট চাল, ৪২০ টাকা করে, মোট ৮৪০ টাকা (পয়সায় 84000). দ্বিতীয় লাইন 409 ডাল ১ কেজি: স্টক মাত্র 1 — এখানেই আসল পরীক্ষা: লুপ প্রথমে চালের স্টক এক কমিয়ে ফেলেছিল, তারপর ডালে ভুল ধরা পড়ল। তৃতীয় লাইন স্টক: চাল ৫ কেজি=1, ডাল ১ কেজি=1 প্রমাণ করে চালের স্টক ০ নয়, ১ — transaction ফাংশন শুরুর ছবি (structuredClone, পর্ব ১৯) ফিরিয়ে এনেছে, অর্থাৎ rollback. তারপর খালি কার্টে 422, ধাপ ডিঙানোর চেষ্টায় 409 pending → shipped চলবে না, চারটি বৈধ ধাপে 200, আর শেষ অবস্থা থেকে বাতিলের চেষ্টায় 409 delivered → cancelled চলবে না. Port নম্বর ইচ্ছা করে ছাপা হয়নি, কারণ port 0 প্রতিবার আলাদা খালি port দেয়।

তিনটি স্তর আলাদা করে দেখুন: repository শুধু ডেটা রাখে; service (placeOrder, changeStatus) HTTP-র কিছুই জানে না, শুধু AppError ছোড়ে; HTTP স্তর সেই error-এর status দেখে উত্তর বানায়, আর ভাঙা JSON-এর মতো অজানা ভুলে দেয় 400. আসল অ্যাপে repository হবে অংশ ৩-এর insertOrder, HTTP স্তর হবে Express; service প্রায় হুবহু থাকে। এখানকার snapshot-rollback একটি process-এর জন্য ঠিক আছে, কিন্তু একসাথে আসা দুটি অনুরোধ থেকে রক্ষা করে না; সেই কাজ ডেটাবেসের transaction আর শর্তসহ UPDATE-এর।

দ্বিতীয় ব্লকটি M1-এর front end: পণ্যের বোতাম, কার্ট, মোট দাম, চেকআউট ফর্ম আর বার্তা — একটি নকল API নিয়ে, যার আকার আসল API-র মতো। কোডটি চালানোর সাথে সাথে একটি নির্দিষ্ট ক্রমে বোতাম চাপে (তাই আউটপুট প্রতিবার এক), তারপর ওপরের পাতায় আপনি নিজেও বোতাম চেপে, নাম-নম্বর লিখে অর্ডার দিয়ে দেখতে পারেন।

const tk = new Intl.NumberFormat('bn-BD', { style: 'currency', currency: 'BDT' });
const money = (paisa) => tk.format(paisa / 100);
const PRODUCTS = [{ id: 1, title: 'চাল ৫ কেজি', price: 42000 }, { id: 2, title: 'ডাল ১ কেজি', price: 14000 },
                  { id: 3, title: 'চা-পাতা', price: 9550 }];
// ===== নকল API — আসল অ্যাপে এখানে fetch('/api/orders', …) =====
const api = {
  stock: { 1: 3, 2: 1, 3: 10 }, nextId: 101,
  async placeOrder(lines) {
    const short = lines.find((l) => l.qty > this.stock[l.id]);
    if (short) throw new Error(`${short.title}: স্টক মাত্র ${this.stock[short.id]}টি`);
    lines.forEach((l) => { this.stock[l.id] -= l.qty; });
    return { id: this.nextId++, status: 'pending' };
  },
};
// ===== pure core: কার্ট = { productId: qty } =====
const addItem = (cart, id) => ({ ...cart, [id]: (cart[id] ?? 0) + 1 });
const removeItem = (cart, id) => {
  const { [id]: qty = 0, ...rest } = cart;
  return qty > 1 ? { ...rest, [id]: qty - 1 } : rest;
};
const toLines = (cart) => Object.entries(cart).map(([id, qty]) => ({ ...PRODUCTS.find((p) => p.id === +id), qty }));
const sum = (lines) => lines.reduce((s, l) => s + l.price * l.qty, 0);
function validateCustomer({ fullName, phone }) {
  if (fullName.trim().length < 2) return 'নাম লিখুন';
  if (!/^01[3-9]\d{8}$/.test(phone)) return 'মোবাইল নম্বর ০১ দিয়ে শুরু, ১১ অঙ্কের হতে হবে';
  return null;
}
// ===== thin UI shell =====
let cart = {};
const $ = (sel) => document.querySelector(sel);
function render() {
  $('#shop').replaceChildren(...PRODUCTS.map((p) => Object.assign(document.createElement('button'),
    { type: 'button', textContent: `+ ${p.title} ${money(p.price)}`, value: `add:${p.id}` })));
  const lines = toLines(cart);
  $('#cart').replaceChildren(...lines.map((l) => {
    const li = document.createElement('li');
    li.textContent = `${l.title} × ${l.qty} = ${money(l.price * l.qty)} `;
    li.append(Object.assign(document.createElement('button'), { type: 'button', textContent: '−', value: `rm:${l.id}` }));
    return li;
  }));
  $('#total').textContent = lines.length ? `মোট ${money(sum(lines))}` : 'কার্ট খালি';
  $('#pay').disabled = lines.length === 0;
}
document.body.addEventListener('click', (ev) => {        // event delegation
  const [act, id] = (ev.target.value ?? '').split(':');
  if (act === 'add') cart = addItem(cart, id);
  if (act === 'rm') cart = removeItem(cart, id);
  if (act === 'add' || act === 'rm') render();
});
async function checkout() {
  const error = validateCustomer({ fullName: $('#fullName').value, phone: $('#phone').value });
  if (error) return ($('#msg').textContent = error);
  $('#pay').disabled = true;
  $('#msg').textContent = 'পাঠানো হচ্ছে…';
  try {
    const order = await api.placeOrder(toLines(cart));
    cart = {};
    $('#msg').textContent = `অর্ডার #${order.id} নেওয়া হয়েছে — অবস্থা: ${order.status}`;
  } catch (e) {
    $('#msg').textContent = e.message;
  }
  render();
  return $('#msg').textContent;
}
$('#form').addEventListener('submit', (ev) => { ev.preventDefault(); checkout(); });
render();
// ===== scripted demo: একই ক্রমে বোতাম চাপা, তাই আউটপুট সবসময় একই =====
(async () => {
  const press = (v) => document.querySelector(`button[value="${v}"]`).click();
  ['add:1', 'add:1', 'add:3', 'rm:1'].forEach(press);
  console.log($('#total').textContent, JSON.stringify(cart));
  $('#fullName').value = 'রাফি'; $('#phone').value = '0171234567';
  console.log(await checkout());
  $('#phone').value = '01712345678';
  console.log(await checkout());
  ['add:2', 'add:2'].forEach(press);
  console.log(await checkout(), '|', $('#total').textContent);
})();

প্রথম লাইন মোট ৫১৫.৫০৳ {"1":1,"3":1} — চাল দুবার যোগ, চা-পাতা একবার, তারপর চাল একবার বাদ। কার্টের key-গুলো string ("1"), কারণ object-এর key সবসময় string; আর পূর্ণসংখ্যার মতো দেখতে key-গুলো object নিজেই ছোট থেকে বড় ক্রমে রাখে — তাই কার্টে পণ্য যোগ করার ক্রম নয়, id-র ক্রম আসে। দ্বিতীয় লাইনে দশ অঙ্কের নম্বরে validateCustomer-এর বার্তা, API-কে ডাকাই হয়নি। তৃতীয় লাইন অর্ডার #101 নেওয়া হয়েছে — অবস্থা: pending. শেষ লাইনে কার্টে দুই প্যাকেট ডাল, কিন্তু স্টক মাত্র একটি — নকল API ভুল ছোড়ে, catch বার্তাটি দেখায়, আর কার্ট খালি হয় না (| মোট ২৮০.০০৳): ক্রেতা পরিমাণ কমিয়ে আবার চেষ্টা করতে পারেন।

কয়েকটি নকশার বিষয় লক্ষ করুন। addItem/removeItem পুরোনো কার্ট বদলায় না, নতুন বানায় (immutable update, পর্ব ১৭); removeItem-এ destructuring-এর rest দিয়ে একটি key বাদ দেওয়ার কৌশল। সব বোতামের জন্য একটিই click listener — event delegation (পর্ব ৩৮); বোতামের value-তে লেখা থাকে কাজ আর id. বার্তার <p>-এ role="status" থাকায় screen reader নতুন বার্তা পড়ে শোনায়। আসল অ্যাপে শুধু api object-এর ভেতরটা fetch('/api/orders', …) হবে; বাকি সব হুবহু।

৬ · মাইলস্টোন ৬ — টেস্ট, lint আর CI

হাতে চালিয়ে দেখা প্রথম দিনে চলে, বিশতম দিনে নয়। পর্ব ৪৮-এ দেখা test pyramid অনুযায়ী তিন স্তরের টেস্ট:

স্তরটুলকী পরীক্ষা করেসংখ্যা ও গতি
Unit testVitestকার্টের pure function, NEXT টেবিলের প্রতিটি সারি, ফোন নম্বরের যাচাইঅনেক; মিলিসেকেন্ডে
Integration testVitest + supertest + আসল PostgreSQLroute → service → ডেটাবেস: 201, 409, 422, 401; rollback-এর পর স্টককয়েক ডজন; সেকেন্ডে
E2E testPlaywrightআসল ব্রাউজারে: পণ্য বাছাই → কার্ট → অর্ডার → সফলতার বার্তাএক-দুটি; ধীর
import request from 'supertest';
import { describe, it, expect, beforeEach } from 'vitest';
import { createApp } from '../src/app.js';
import { makeService } from '../src/services/orders.js';
import { pool, resetDb } from './db.js';               // টেস্টের আলাদা ডেটাবেস

const app = createApp({ service: makeService(pool) });
beforeEach(() => resetDb({ products: [{ id: 1, title: 'চাল', price_paisa: 42000, stock: 1 }] }));

describe('POST /api/orders', () => {
  it('স্টকের বেশি চাইলে 409, আর স্টক অপরিবর্তিত', async () => {
    await request(app).post('/api/orders')
      .send({ customer: { fullName: 'রাফি', phone: '01712345678' }, items: [{ productId: 1, qty: 2 }] })
      .expect(409);
    const { rows } = await pool.query('SELECT stock FROM products WHERE id = 1');
    expect(rows[0].stock).toBe(1);
  });
});
import { test, expect } from '@playwright/test';

test('ক্রেতা একটি অর্ডার দিতে পারেন', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: /চাল ৫ কেজি/ }).click();
  await page.getByLabel('নাম').fill('রাফি');
  await page.getByLabel('মোবাইল').fill('01712345678');
  await page.getByRole('button', { name: 'অর্ডার দিন' }).click();
  await expect(page.getByRole('status')).toContainText('অর্ডার #');
});

E2E টেস্টটি element খোঁজে role আর label দিয়ে — CSS class দিয়ে নয়। এর দুটি লাভ: class বদলালে টেস্ট ভাঙে না, আর টেস্ট পাস করা মানে screen reader-ও বোতাম আর ঘরগুলো চিনতে পারে। টেস্ট এখানে accessibility-এর পাহারাদারও।

Lint, format আর CI

ESLint সম্ভাব্য বাগ ধরে (অব্যবহৃত ভ্যারিয়েবল, ভুলে যাওয়া await), Prettier ফরম্যাট নিয়ে তর্ক শেষ করে (পর্ব ৪৬)। কিন্তু “সবাই মনে করে চালাবেন” — এমন নিয়ম কখনো টেকে না। তাই CI/CD: প্রতিটি push-এ একটি পরিষ্কার মেশিনে সব যাচাই নিজে থেকে চলে। GitHub Actions-এ একটি YAML ফাইলই যথেষ্ট:

name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: test }
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 24, cache: npm }
      - run: npm ci                      # lockfile হুবহু মেনে install
      - run: npm run lint
      - run: npm run migrate -w api
      - run: npm test --workspaces
      - run: npx playwright install --with-deps chromium && npm run e2e
      - run: npm run build -w web
  1. pushgit pushশুরু
  2. installnpm cilockfile মেনে
  3. linteslintদ্রুত ব্যর্থতা
  4. testvitest · e2eআসল DB-সহ
  5. buildvite builddist/
  6. deploymain শাখায়migrate, তারপর চালু
  7. smoke test/healthzসত্যিই চলছে?
ধাপ ১ঘটনা: push বা pull request
git push origin feature/live-status
→ GitHub একটি নতুন, খালি Linux মেশিন চালু করে
আপনার কম্পিউটারের কোনো কিছু সেখানে নেই — এটিই উদ্দেশ্য
ধাপ ২npm install নয়, npm ci
npm ci   → package-lock.json-এর হুবহু সংস্করণ
         → lock আর package.json না মিললে থামে
একই কোড = একই dependency, প্রতিবার
ধাপ ৩সস্তা পরীক্ষা আগে
eslint .  &&  prettier --check .
✖ 'total' is assigned a value but never used
→ এখানেই লাল; ধীর টেস্টে সময় নষ্ট হয় না
ধাপ ৪unit, integration, E2E
✓ cart.test.js (12)
✓ orders.test.js (9)  — service container-এর আসল PostgreSQL
✓ checkout.spec.js (1)
একটিও ব্যর্থ হলে merge বোতাম বন্ধ
ধাপ ৫production-এর ফাইল
vite build
dist/index.html
dist/assets/index-3f9a1c.js   ← নামে content hash
hash থাকায় এই ফাইল বছরখানেক cache করা নিরাপদ
ধাপ ৬শুধু main শাখা
dist/  → static host
api    → npm run migrate  (আগে ডেটাবেস)
       → নতুন process চালু, পুরোনোটি সুন্দরভাবে বন্ধ
secret আসে CI-এর secret store থেকে, কোড থেকে নয়
ধাপ ৭live-এ এক নজর
curl -fsS https://api.dokanghor.com/healthz
{"ok":true,"db":"ok"}
ব্যর্থ হলে → সতর্কবার্তা, আর আগের সংস্করণে ফেরা
  1. Push — সব শুরু। প্রতিটি push বা pull request-এ GitHub একটি একদম নতুন মেশিন চালু করে। আপনার কম্পিউটারের লুকানো নির্ভরতা সেখানে নেই, তাই “আমার মেশিনে তো চলে” সমস্যা এখানেই ধরা পড়ে।
  2. Install — হুবহু একই dependency. npm ci lockfile-এর ঠিক সংস্করণগুলো বসায়, আর lockfile ও package.json না মিললে থেমে যায়।
  3. Lint — সবচেয়ে সস্তা পরীক্ষা আগে। কয়েক সেকেন্ডে ফরম্যাট আর সাধারণ ভুল ধরা পড়ে। ভুল যত আগে ধরা পড়ে, তত ভালো — fail fast.
  4. Test — আসল ডেটাবেসসহ। Workflow-এর services একটি PostgreSQL container চালায়; migration চলে, তারপর তিন স্তরের টেস্ট।
  5. Build — production-এর ফাইল। Vite ছোট করা, ভাগ করা JavaScript বানায়; ফাইলের নামে content hash থাকে, তাই ব্রাউজার নিশ্চিন্তে দীর্ঘ সময় cache করতে পারে — কোড বদলালে নামই বদলে যায়।
  6. Deploy — শুধু main থেকে। Front end যায় static host-এ; API-তে আগে migration, তারপর নতুন process চালু আর পুরোনোটি চলমান অনুরোধ শেষ করে বন্ধ হয় (graceful shutdown). Database-এর পাসওয়ার্ডের মতো secret আসে CI-এর secret store থেকে।
  7. Smoke test — সত্যিই চলছে তো? Deploy “সফল” মানেই অ্যাপ চলছে না। তাই live ঠিকানায় একটি health check অনুরোধ, এটিই smoke test. ব্যর্থ হলে সতর্কবার্তা আর আগের সংস্করণে ফিরে যাওয়া। এই পুরো স্বয়ংক্রিয় পথটিই deployment pipeline.

৭ · মাইলস্টোন ৭ — deploy: অ্যাপকে ইন্টারনেটে তোলা

Deploy-এর আগে একটি নীতি: কোড সব জায়গায় এক, পার্থক্য শুধু configuration-এ। ডেটাবেসের ঠিকানা, session-এর গোপন চাবি, front end-এর ঠিকানা — এসব কোডে নয়, environment variable-এ:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://USER:PASSWORD@HOST:5432/dokanghor
SESSION_SECRET=লম্বা-এলোমেলো-মান-এখানে-নয়-secret-store-এ
WEB_ORIGIN=https://dokanghor.com

.env ফাইলটি .gitignore-এ থাকে, কখনো git-এ যায় না; git-এ যায় শুধু মানহীন .env.example. সার্ভার চালুর সময় দরকারি variable না পেলে স্পষ্ট বার্তা দিয়ে সাথে সাথে থামুন। Front end-এ সতর্ক থাকুন: Vite শুধু VITE_ দিয়ে শুরু হওয়া variable ব্রাউজারের কোডে বসায়, আর সেগুলো সবাই দেখতে পায় — সেখানে কোনো secret রাখা চলবে না।

কোথায় রাখবেন — দুটি পরিচিত পথ

অংশManaged platform-এর পথনিজের VPS-এর পথ
Front end (dist/)Static hosting: Netlify, Vercel, Cloudflare Pages ইত্যাদি — git-এ push করলেই build ও প্রকাশএকই সার্ভারে nginx দিয়ে dist/ পরিবেশন
APIRender, Railway, Fly.io-র মতো platform — Dockerfile বা npm start দিলেই চালায়, HTTPS নিজেরাই দেয়Ubuntu সার্ভারে Node, process manager PM2, সামনে nginx
ডেটাবেসPlatform-এর managed PostgreSQL — backup ও হালনাগাদ তাদের দায়িত্বেএকই বা আলাদা সার্ভারে PostgreSQL — backup আপনার দায়িত্বে
কখন মানায়দ্রুত শুরু, কম রক্ষণাবেক্ষণ, server চালানোর অভিজ্ঞতা কমপুরো নিয়ন্ত্রণ চান, Linux শিখতে চান, একাধিক প্রজেক্ট এক সার্ভারে

প্রতিটি সেবার সুবিধা, সীমা আর খরচের নিয়ম বদলায়; বেছে নেওয়ার আগে তাদের বর্তমান ডকুমেন্টেশন পড়ুন। দুই পথেই নকশা একই থাকে।

Docker — ঐচ্ছিক, কিন্তু কাজের

একটি container image-এ অ্যাপ, ঠিক Node সংস্করণ আর সব dependency একসাথে বাঁধা থাকে — যেখানেই চালান, একই পরিবেশ। অনেক platform সরাসরি Dockerfile নেয়:

FROM node:24-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev          # শুধু production dependency
COPY src ./src
COPY migrations ./migrations
USER node                      # root হিসেবে নয়
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "src/server.js"]

package*.json আগে কপি করলে শুধু কোড বদলানোয় npm ci-এর cache করা ধাপ আবার চলে না। আর এই পর্ব লেখার সময় (২০২৬) Node 20-এর আনুষ্ঠানিক সহায়তা শেষ হয়ে গেছে; production-এ সবসময় চলতি LTS সংস্করণ নিন।

VPS-এর পথ — nginx, PM2 আর HTTPS

server {
  server_name dokanghor.com;
  root /var/www/dokanghor/dist;                  # Vite-এর build
  location / { try_files $uri /index.html; }     # SPA-র সব পথে index.html
  location /api/ {
    proxy_pass http://127.0.0.1:3000;            # Node শুধু localhost-এ শোনে
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;                         # SSE-র বার্তা সাথে সাথে যাক
    proxy_read_timeout 1h;
  }
}
npm ci --omit=dev && npm run migrate
pm2 start src/server.js --name dokanghor-api    # crash হলে নিজে থেকে আবার চালু
pm2 startup && pm2 save                         # সার্ভার reboot-এর পরও চালু
sudo certbot --nginx -d dokanghor.com           # Let's Encrypt থেকে HTTPS certificate
sudo ufw allow 'Nginx Full' && sudo ufw allow OpenSSH
  1. ব্রাউজারhttps://DNS → সার্ভারের IP
  2. nginx:443TLS, static, proxy
  3. Node (PM2)127.0.0.1:3000Express API
  4. PostgreSQL:5432শুধু ভেতর থেকে
স্তর ১ঠিকানা থেকে সংযোগ
dokanghor.com  →  DNS A record  →  203.0.113.10
শুধু 80, 443 আর SSH খোলা (ufw firewall)
স্তর ২reverse proxy
HTTPS এখানেই খোলে (TLS termination)
/            → dist/ থেকে static ফাইল
/api/…       → proxy_pass 127.0.0.1:3000
gzip, cache header, বড় body আটকানো
স্তর ৩Node বাইরে থেকে দেখা যায় না
app.set('trust proxy', 1);  // X-Forwarded-Proto বিশ্বাস
→ Secure cookie আর আসল IP-র rate limit ঠিক চলে
PM2: crash → restart · log ফাইল · reboot-এর পর চালু
স্তর ৪সবচেয়ে মূল্যবান অংশ
listen_addresses = 'localhost'
অ্যাপের ডেটাবেস-ব্যবহারকারী: শুধু নিজের টেবিলে অধিকার
প্রতি রাতে pg_dump → অন্য জায়গায় কপি
  1. ব্রাউজার থেকে সার্ভার। DNS নামটিকে IP ঠিকানায় বদলায় (পর্ব ০২)। Firewall শুধু HTTP, HTTPS আর SSH খোলা রাখে।
  2. nginx — সামনের দারোয়ান। একটি reverse proxy: HTTPS-এর এনক্রিপশন খোলে, static ফাইল নিজেই দ্রুত পাঠায়, আর শুধু /api/ অনুরোধ ভেতরের Node-এর কাছে পাঠায়। SSE-র জন্য proxy_buffering off জরুরি — নইলে nginx বার্তা জমিয়ে রাখে, লাইভ আর লাইভ থাকে না। Certificate দেয় Let's Encrypt; certbot নিজে থেকে নবায়ন করে।
  3. Node — শুধু ভেতরে শোনে। Express শোনে 127.0.0.1-এ, তাই বাইরের কেউ nginx এড়িয়ে সরাসরি পৌঁছাতে পারে না। trust proxy না দিলে Express ভাববে সব অনুরোধ HTTP-তে আর একই IP থেকে আসছে — express-session-এর মতো library Secure cookie বসাবে না, আর rate limit সবাইকে একসাথে আটকাবে। PM2 process পাহারা দেয়।
  4. PostgreSQL — সবচেয়ে ভেতরে। কেবল localhost থেকে সংযোগ, অ্যাপের ব্যবহারকারীর সীমিত অধিকার (least privilege). কোড হারালে git থেকে ফেরে, ডেটা ফেরে শুধু backup থেকে।

৮ · launch-এর আগে ও পরে — চেকলিস্ট, log, backup আর হালনাগাদ

অ্যাপ live হওয়া শেষ নয়, শুরু। নিচের তিনটি চেকলিস্ট launch-এর আগে একবার, আর প্রতিটি বড় পরিবর্তনের পর আবার দেখুন।

নিরাপত্তা (পর্ব ৪৩)

গতি (পর্ব ৫৩)

Accessibility

Launch-এর পরে — চোখ, স্মৃতি আর যত্ন

📜 Log

প্রতিটি অনুরোধে এক লাইন JSON — সময়, পথ, status, কত মিলিসেকেন্ড, আর একটি request id. এমন structured logging (যেমন pino library দিয়ে) পরে খোঁজা যায়। ফোন নম্বর বা পাসওয়ার্ড log-এ লিখবেন না।

কী ঘটছে

🚨 Error

অপ্রত্যাশিত error-এর জন্য একটি error monitoring সেবা বা অন্তত সতর্কবার্তা; process.on('unhandledRejection')-এ log করে নিয়ন্ত্রিতভাবে বন্ধ, PM2 বা platform আবার চালু করবে।

কী ভাঙছে

💾 Backup

প্রতি রাতে pg_dump, অন্য জায়গায় কপি, আর মাসে একবার আসলেই ফিরিয়ে দেখা। পরীক্ষা না করা backup আসলে backup নয় — শুধু একটি আশা।

কী হারাবেন না

🔄 হালনাগাদ

Dependabot-এর মতো টুল dependency হালনাগাদের pull request খোলে, CI যাচাই করে। Uptime monitor নিয়মিত /healthz দেখে।

কী পুরোনো হচ্ছে

এই চারটি মিলে observability-র শুরু — অ্যাপের ভেতরে কী ঘটছে তা বাইরে থেকে বোঝার ক্ষমতা।

৯ · Portfolio, ইন্টারভিউ আর বাড়ানোর ধারণা

একটি শেষ করা, live, টেস্টসহ প্রজেক্ট দশটি অর্ধেক টিউটোরিয়ালের চেয়ে বেশি কথা বলে — যদি ঠিকভাবে দেখান:

  • README — প্রজেক্টের প্রথম পাতা: এক লাইনে কী আর কার জন্য; live লিংক; ২–৩টি screenshot বা একটি ছোট GIF; স্থাপত্যের ছবি; নিজের কম্পিউটারে চালানোর ধাপ; টেস্ট চালানোর কমান্ড; আর “নকশার সিদ্ধান্ত ও সীমা” অংশ — যেমন “SSE-র bus একটি process-এর মধ্যে; বড় হলে LISTEN/NOTIFY লাগবে”। ভালো README দেখায় আপনি ভাবেন।
  • Live লিংক কাজ করুক: কিছু platform অলস সার্ভার ঘুম পাড়িয়ে রাখে, প্রথম অনুরোধে দেরি হয় — README-তে সেটি লিখে দিন। রিভিউয়ারের জন্য একটি নমুনা দোকানি অ্যাকাউন্ট দিন।
  • সৎ থাকুন: কোন অংশে AI সাহায্য করেছে আর আপনি কী যাচাই করেছেন, জিজ্ঞেস করলে খোলাখুলি বলুন। ইন্টারভিউতে প্রতিটি লাইন ব্যাখ্যা করতে না পারলে প্রজেক্ট উল্টো ক্ষতি করে।
পরীক্ষা ও ইন্টারভিউতে
  • “আপনার প্রজেক্টটি দুই মিনিটে বলুন।” — সমস্যা (কার, কী কষ্ট) → সমাধান (তিন অংশের স্থাপত্য) → একটি কঠিন সমস্যা ও আপনার সমাধান (যেমন শর্তসহ UPDATE দিয়ে oversell ঠেকানো) → ফল (live লিংক, টেস্ট)।
  • “Transaction কেন লাগল?” — স্টক কমানো আর অর্ডার লেখা একসাথে সফল বা একসাথে বাতিল হতে হবে; মাঝপথে ভুল হলে ROLLBACK, নইলে স্টক কমবে কিন্তু অর্ডার থাকবে না।
  • “WebSocket না নিয়ে SSE কেন?” — শুধু সার্ভার → ব্রাউজার বার্তা দরকার; SSE সাধারণ HTTP, নিজে থেকে reconnect করে, proxy-র সাথে সহজ।
  • “JWT না নিয়ে session cookie কেন?” — একটি সার্ভার, একটি ডেটাবেস; সার্ভারের session সাথে সাথে বাতিল করা যায়, আর HttpOnly cookie JavaScript থেকে চুরি করা কঠিন।
  • “আবার শুরু করলে কী অন্যভাবে করতেন?” — সৎ, নির্দিষ্ট উত্তর দিন, যেমন “শুরু থেকেই dev-এ PostgreSQL রাখতাম”; এই প্রশ্নে শেখার ক্ষমতা যাচাই হয়।

বাড়ানোর ধারণা

💳 অনলাইন পেমেন্ট

SSLCommerz বা bKash-এর মতো সেবার sandbox পরিবেশ দিয়ে শুরু করার কথা ভাবুন — তাদের বর্তমান ডকুমেন্টেশন পড়ে। মূল নিয়ম: পেমেন্টের ফল বিশ্বাস করুন শুধু সার্ভার-থেকে-সার্ভার যাচাইয়ে, ব্রাউজারের redirect-এ নয়।

পর্ব ৩৬, ৪৩, ৫০

📊 দোকানির ড্যাশবোর্ড

আজকের বিক্রি, কম স্টকের সতর্কতা, অবস্থা অনুযায়ী অর্ডার — SQL-এর GROUP BY আর SSE-তে লাইভ তালিকা।

পর্ব ৫১, ৫২

📴 অফলাইনেও কার্ট

Service worker দিয়ে app shell cache, কার্ট IndexedDB-তে; নেটওয়ার্ক ফিরলে অর্ডার পাঠানো — একটি PWA.

পর্ব ৪০, ৪৪

🌐 বাংলা ও ইংরেজি

বার্তাগুলো একটি অনুবাদ-ফাইলে, ভাষা বদলের বোতাম, আর Intl-এ bn-BD/en-BD — i18n-এর হাতে-খড়ি।

পর্ব ২০

১০ · AI-এর যুগে — পুরো প্রজেক্টে AI, দায়িত্বশীলভাবে

বড় প্রজেক্টে AI সময় বাঁচায় — কাঠামো, YAML, টেস্টের খসড়া; বিপদও বড়, কারণ কোড অনেক আর প্রতিটি লাইন পড়ার ধৈর্য কমে। চারটি নিয়ম:

  • কাঠামো নিন, তারপর প্রতিটি লাইন বুঝুন: AI-কে দিয়ে Express-এর skeleton বা Dockerfile বানানো ঠিক আছে; কিন্তু যে লাইনের কারণ ব্যাখ্যা করতে পারেন না, সেটি commit করবেন না। প্রশ্ন করুন: “এই proxy_buffering off না থাকলে কী হতো?”
  • AI দিয়ে টেস্ট লেখান, তারপর টেস্টের রিভিউ করুন: AI প্রায়ই এমন টেস্ট লেখে যা কোডের বর্তমান আচরণ হুবহু নকল করে — বাগসহ। প্রতিটি টেস্টকে একবার ইচ্ছা করে ভেঙে দেখুন, সত্যিই লাল হয় কি না।
  • নিরাপত্তা রিভিউয়ে দ্বিতীয় চোখ: নির্দিষ্ট route আর middleware দিয়ে AI-কে threat খুঁজতে বলুন — কিন্তু প্রতিটি দাবি নিজে যাচাই করুন।
  • Secret কখনো নয়: .env, ডেটাবেসের পাসওয়ার্ড, API key, আসল ক্রেতার ফোন নম্বর — কোনোটি AI টুলে পেস্ট করবেন না। ভুল করে পেস্ট হলে ধরে নিন সেটি ফাঁস হয়েছে, আর সাথে সাথে বদলে ফেলুন।

নিচের prompt-টি একটি route-এর নিরাপত্তা রিভিউয়ের জন্য। শুধু নিজের লেখা route আর middleware বসান — কোনো secret বা আসল ডেটা নয়:

আমি Node.js + Express + PostgreSQL দিয়ে একটি ছোট দোকানের অর্ডার API বানিয়েছি। নিচে POST /api/orders আর PATCH /api/orders/:id/status route, তাদের middleware আর service-এর কোড দিলাম। প্রসঙ্গ: ১. ক্রেতা লগইন ছাড়া অর্ডার দেন; শুধু দোকানি (HttpOnly session cookie) অবস্থা বদলান। ২. অবস্থার নিয়ম: pending → paid/cancelled, paid → packed/cancelled, packed → shipped, shipped → delivered. ৩. কোড আবার লিখে দিও না। শুধু রিভিউ করো: ক) কোন নির্দিষ্ট অনুরোধে (method, body, header সহ) নিরাপত্তা বা ব্যবসার নিয়ম ভাঙা যায় — যেমন দাম বদলানো, স্টকের বেশি কেনা, লগইন ছাড়া অবস্থা বদলানো, একসাথে দুটি অনুরোধ। খ) প্রতিটির জন্য একটি supertest টেস্টের খসড়া দাও, যা এখন ব্যর্থ হওয়া উচিত। গ) কোনটি নিশ্চিত আর কোনটি অনুমান, আলাদা করে লেখো; নিশ্চিত না হলে "নিশ্চিত নই" বলো। [এখানে আপনার route, middleware আর service-এর কোড — কোনো .env বা পাসওয়ার্ড নয়]

AI-এর উত্তর হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১দোকানঘরকে একটি সার্ভারে (monolith) বানালাম। বড় কোম্পানিরা তো microservices ব্যবহার করে — আমরা কি ভুল পথে?

দোকানঘরের API একটি process, একটি codebase, একটি ডেটাবেস — একে বলে monolith. এর বিপরীতে microservices: পণ্য, অর্ডার, লগইন — প্রতিটি আলাদা ছোট সেবা, আলাদা deploy, প্রায়ই আলাদা ডেটাবেস, আর নিজেদের মধ্যে নেটওয়ার্কে কথা বলে। ২০১০-এর দশকে বড় কোম্পানির গল্পে এটি এত জনপ্রিয় হয় যে অনেক ছোট দলও প্রথম দিন থেকে দশটি সেবা দিয়ে শুরু করে।

২০১৫ সালের জুনে Martin Fowler তাঁর ওয়েবসাইটে “MonolithFirst” নামে একটি ছোট লেখা প্রকাশ করেন। তাঁর পর্যবেক্ষণ: তিনি যত সফল microservices-এর গল্প শুনেছেন, প্রায় সবগুলো শুরু হয়েছিল একটি monolith হিসেবে, যা বড় হয়ে সমস্যা তৈরি করার পর ভাঙা হয়েছে; আর শুরু থেকেই microservices দিয়ে বানানো অনেক প্রজেক্ট গুরুতর সমস্যায় পড়েছে। কারণ, সেবার সীমানা ঠিক কোথায় টানতে হবে, তা নতুন প্রজেক্টের শুরুতে কেউ জানে না। ভুল জায়গায় সীমানা টানলে প্রতিটি নতুন বৈশিষ্ট্যে তিনটি সেবা একসাথে বদলাতে হয় — monolith-এর সব অসুবিধা, সাথে নেটওয়ার্কের সব জটিলতা। এর পাল্টা যুক্তিও আছে — monolith পরে ভাঙা কঠিন; বিতর্কটি আজও চলে।

Microservices-এর দাম চোখে পড়ে কম। আমাদের অর্ডারের transaction — স্টক কমানো আর অর্ডার লেখা — এক ডেটাবেসে এক BEGIN…COMMIT. পণ্য আর অর্ডার আলাদা সেবা হলে এই সরল transaction আর থাকে না; লাগে saga-র মতো জটিল কৌশল, যেখানে প্রতিটি ধাপের উল্টো “ক্ষতিপূরণ” কাজ লিখতে হয়। Microservices মূলত একটি সংগঠনের সমস্যার সমাধান — অনেক দল যেন একে অপরকে না আটকে আলাদাভাবে deploy করতে পারে (Conway-র সূত্র, ১৯৬৮: সিস্টেমের গঠন সংগঠনের যোগাযোগের গঠনের প্রতিচ্ছবি হয়)।

দোকানঘরের মতো এক বা দুজনের প্রজেক্টে monolith-ই সঠিক। তবে ভালো monolith-এর ভেতরে পরিষ্কার সীমানা থাকে — আমাদের route, service, repository আলাদা ফাইলে, service HTTP চেনে না। একে কখনো “modular monolith” বলা হয়। কখনো আলাদা করতে হলে এই সীমানাগুলোই হবে ভাঙার রেখা।

মূল উপলব্ধি: স্থাপত্য বেছে নিন আজকের সমস্যা আর দলের আকার দেখে, বড় কোম্পানির গল্প দেখে নয় — ভেতরে পরিষ্কার সীমানাসহ একটি monolith দিয়ে শুরু করা সাধারণত সবচেয়ে নিরাপদ পথ।

প্র ০২কেন configuration environment variable-এ, log stdout-এ, আর process “যেকোনো সময় মরতে প্রস্তুত”? Twelve-factor app ধারণাটি কোথা থেকে এলো?

২০১১ সালের দিকে Heroku-র সহ-প্রতিষ্ঠাতা Adam Wiggins একটি ছোট ওয়েবসাইট প্রকাশ করেন: “The Twelve-Factor App”. হাজারো অ্যাপ চালানোর অভিজ্ঞতা থেকে বারোটি নীতি — কোন অ্যাপ সহজে চলে, বড় হয়, সরানো যায়। কয়েকটি আমাদের প্রজেক্টে সরাসরি দেখা যায়।

Config environment-এ (factor III): যা deploy থেকে deploy বদলায় — ডেটাবেসের ঠিকানা, secret — তা কোডে নয়। পরীক্ষাটি সহজ: এই মুহূর্তে কি repository-টি public করে দেওয়া যায়, কোনো secret ফাঁস না করে? আমাদের .env.example আর DATABASE_URL এই নীতি। Dependency স্পষ্ট ঘোষণা (II): package.json আর lockfile; সার্ভারে আগে থেকে কিছু বসানো আছে বলে ধরে নেওয়া চলবে না। Backing service সংযুক্ত সম্পদ (IV): PostgreSQL একটি ঠিকানা মাত্র — নিজের সার্ভারের হোক বা managed সেবার, কোড বদলায় না।

Process stateless (VI) আর disposable (IX): process-এর memory-তে এমন কিছু রাখা যাবে না যা হারালে ক্ষতি; session থাকে ডেটাবেসে, memory-তে নয়। Process দ্রুত চালু হবে আর SIGTERM পেলে চলমান অনুরোধ শেষ করে সুন্দরভাবে বন্ধ হবে — তাহলে deploy, crash বা সার্ভার বদল সব সহজ। আমাদের SSE-র bus এখানে একটি সচেতন ব্যতিক্রম, আর সেটি README-তে লিখে রাখতে বলেছি। Log একটি event stream (XI): অ্যাপ log ফাইল সামলায় না, শুধু stdout-এ লেখে; জমানো, ঘোরানো আর খোঁজা পরিবেশের দায়িত্ব — PM2, Docker বা platform. Dev/prod parity (X): dev আর production যত কাছাকাছি, তত কম চমক — এ কারণেই dev-এও PostgreSQL রাখার পরামর্শ।

লেখাটি এক দশকেরও বেশি পুরোনো; environment variable-এ secret রাখার ঝুঁকির কারণে বড় প্রতিষ্ঠান এখন আলাদা secret manager-ও ব্যবহার করে। তবু আজকের প্রায় সব cloud platform-এর আচরণে এই নীতিগুলোর ছাপ স্পষ্ট।

মূল উপলব্ধি: যে অ্যাপ নিজের পরিবেশ সম্পর্কে কম ধরে নেয় — config বাইরে থেকে নেয়, state বাইরে রাখে, log stdout-এ দেয়, আর যেকোনো সময় থামতে প্রস্তুত — সেটিই সহজে deploy, বড় আর সারাই করা যায়।

প্র ০৩“আমার মেশিনে তো চলে!” — এই পুরোনো সমস্যার সমাধান হিসেবে container কীভাবে এলো, আর Docker আসলে কী করে?

দৃশ্যটি প্রায় সব দলের চেনা। ডেভেলপারের ল্যাপটপে অ্যাপ চমৎকার চলে; সার্ভারে গিয়ে ভাঙে। কারণ খুঁজতে গিয়ে দেখা যায় — ল্যাপটপে Node 22, সার্ভারে 18; ল্যাপটপে একটি system library আছে, সার্ভারে নেই; Windows-এ ফাইলের নামে বড়-ছোট হাতের অক্ষর এক, Linux-এ আলাদা।

সমাধানের চেষ্টা পুরোনো। ১৯৭৯ সালের Unix-এ chroot একটি process-কে ফাইল-ব্যবস্থার একটি অংশে আটকাতে পারত। ২০০০ সালে FreeBSD jails, ২০০৪ সালে Solaris Zones আরও আলাদা করার সুবিধা আনে। Linux kernel-এ দুটি মূল ভিত আসে: namespace (process কী দেখতে পাবে — নিজের process তালিকা, নেটওয়ার্ক, ফাইল) আর Google-এর প্রকৌশলীদের শুরু করা cgroups (process কতটা CPU আর memory পাবে)। কিন্তু এসব ব্যবহার করা ছিল বিশেষজ্ঞের কাজ।

২০১৩ সালের মার্চে PyCon-এ dotCloud কোম্পানির Solomon Hykes একটি পাঁচ মিনিটের lightning talk-এ Docker দেখান, আর সেই মাসেই এটি open source হয়। Docker নতুন kernel প্রযুক্তি আনেনি; এনেছিল সহজ অভিজ্ঞতা: একটি সাধারণ Dockerfile, স্তরে স্তরে বানানো image যা cache হয়, আর Docker Hub-এ image ভাগাভাগি। নামের রূপকটি জাহাজের মানক container থেকে — চট্টগ্রাম বন্দর হোক বা রটারড্যাম, ভেতরে কী আছে না জেনেই একইভাবে তোলা-নামানো যায়।

মনে রাখবেন, container virtual machine নয়: সব container একই host kernel ভাগ করে, তাই হালকা আর দ্রুত চালু হয়, কিন্তু আলাদা করার মাত্রাও VM-এর চেয়ে কম — root হিসেবে না চালানো (আমাদের USER node) এ কারণেই জরুরি। আর container “আমার মেশিনে চলে” সমস্যার পুরো সমাধান নয়: image-এর ভেতরের পরিবেশ এক হয়, কিন্তু ভুল environment variable, ভিন্ন ডেটাবেসের ডেটা বা নেটওয়ার্কের পার্থক্য তখনো থাকে। আমাদের CI-এর খালি মেশিন আর প্রতিটি push-এ টেস্ট সেই বাকি অংশ পাহারা দেয়।

মূল উপলব্ধি: container পরিবেশকেও কোডের মতো লিখে রাখা, version করা আর ভাগ করা যায় এমন জিনিস বানায় — Docker-এর বিপ্লব প্রযুক্তিতে যতটা, তার চেয়ে বেশি ব্যবহারের সহজতায়।

প্র ০৪প্রতিটি push-এ টেস্ট আর ঘনঘন deploy — এতে ঝুঁকি বাড়ে না কমে? Continuous delivery আর DORA metrics কী বলে?

সহজাত বুদ্ধি বলে, deploy বিপজ্জনক — তাই কম করাই ভালো, মাসে একবার, অনেক সতর্কতায়। তখন মাসের পর মাস বদল জমে, আর কিছু ভাঙলে কোন বদলে ভাঙল খুঁজতে দিনের পর দিন লাগে। গবেষণা আর অভিজ্ঞতা দেখিয়েছে উল্টোটাই সত্য: ছোট বদল ঘনঘন deploy করলে ঝুঁকি কমে, কারণ প্রতিটি deploy ছোট, বোঝা সহজ, আর ভাঙলে কারণ প্রায় স্পষ্ট।

এই চিন্তার শিকড় ১৯৯০-এর দশকের শেষে Kent Beck-এর Extreme Programming-এ, যেখানে continuous integration — দিনে বহুবার সবার কোড মেলানো আর টেস্ট চালানো — একটি মূল চর্চা ছিল; ২০০০ সালে Martin Fowler-এর “Continuous Integration” লেখাটি একে ব্যাপক পরিচিতি দেয়। ২০১০ সালে Jez Humble আর David Farley-র বই “Continuous Delivery” পরের ধাপের কথা বলে: প্রতিটি বদল একটি স্বয়ংক্রিয় deployment pipeline-এর ভেতর দিয়ে যাবে — build, টেস্ট, staging — যাতে সফটওয়্যার সবসময় release করার মতো অবস্থায় থাকে, আর release হয় একটি বোতাম চাপার মতো সাধারণ, ভয়ের নয়। Continuous deployment আরও এক ধাপ: সব যাচাই পাস করলে মানুষের অনুমোদন ছাড়াই production-এ।

দাবিটি কি শুধু মতামত? এখানে আসে DORA — DevOps Research and Assessment. Nicole Forsgren, Jez Humble আর Gene Kim কয়েক বছর ধরে হাজার হাজার প্রতিষ্ঠানের জরিপ বিশ্লেষণ করেন (“State of DevOps” রিপোর্ট), যার সারকথা ২০১৮ সালের বই “Accelerate”-এ। তাঁরা চারটি মাপকাঠি দাঁড় করান — DORA metrics: deploy কত ঘনঘন হয়; একটি commit production-এ পৌঁছাতে কত সময় লাগে (lead time); কত শতাংশ deploy সমস্যা তৈরি করে (change failure rate); আর সমস্যা হলে সেবা ফেরাতে কত সময় লাগে। তাঁদের মূল আবিষ্কার: সেরা দলগুলো গতি আর স্থিতিশীলতা — দুটোতেই একসাথে ভালো; একটির বিনিময়ে আরেকটি নয়। ২০১৮ সালের শেষে DORA দলটি Google-এর অংশ হয়, আর রিপোর্ট এখনো প্রকাশিত হয়।

সতর্কতাও আছে — মাপকাঠি লক্ষ্য হয়ে গেলে মানুষ সংখ্যা বাড়াতে কৌশল খোঁজে (Goodhart-এর সূত্র); দিনে বিশটি অর্থহীন deploy উন্নতি নয়। মাপকাঠি আয়না, পুরস্কারের তালিকা নয়।

মূল উপলব্ধি: ঝুঁকি আসে বড়, বিরল, হাতে করা release থেকে — ছোট বদল, স্বয়ংক্রিয় pipeline আর দ্রুত ফেরার ক্ষমতা একসাথে গতি আর নির্ভরযোগ্যতা দুটোই বাড়ায়।

প্র ০৫launch-এর পর কিছু ভাঙলে কীভাবে জানব, আর ভাঙার পর কী করব? Observability আর blameless postmortem কী শেখায়?

Monitoring প্রশ্নের উত্তর দেয় যা আপনি আগে থেকে জানতেন জিজ্ঞেস করতে হবে: “সার্ভার চলছে? CPU কত?” কিন্তু আসল সমস্যাগুলো সাধারণত অপ্রত্যাশিত: “শুধু Grameenphone ব্যবহারকারীদের SSE বারবার কেটে যাচ্ছে কেন?” Observability শব্দটি এসেছে নিয়ন্ত্রণ-তত্ত্ব থেকে — Rudolf Kálmán ১৯৬০ সালে এর সংজ্ঞা দেন: একটি সিস্টেমের বাইরের আউটপুট দেখে ভেতরের অবস্থা কতটা বোঝা যায়। সফটওয়্যারে এর মানে: নতুন, আগে না ভাবা প্রশ্নের উত্তরও আপনার ডেটা থেকে খুঁজে বের করা যায় কি না।

প্রচলিতভাবে তিনটি ধরনের সংকেত বলা হয়। Log — প্রতিটি ঘটনার বিস্তারিত বিবরণ; structured হলে খোঁজা যায়। Metric — সময়ের সাথে সংখ্যা: প্রতি মিনিটে অনুরোধ, error-এর হার, উত্তরের সময়ের 95th percentile. Trace — একটি অনুরোধের পুরো যাত্রা, কোন ধাপে কত সময়; Google-এর ২০১০ সালের Dapper প্রবন্ধ বড় পরিসরের distributed tracing-এর ধারণাকে পরিচিত করে, আর আজ OpenTelemetry প্রকল্প (২০১৯ সালে OpenTracing আর OpenCensus মিলে তৈরি) এর একটি উন্মুক্ত মানদণ্ড। আমাদের ছোট অ্যাপে প্রতিটি log লাইনে request id রাখাই trace-এর প্রথম ধাপ।

ভাঙার পরের অংশ আরও গুরুত্বপূর্ণ। ২০১২ সালে Etsy-র John Allspaw “Blameless PostMortems and a Just Culture” লেখায় যুক্তি দেন: ঘটনার পর দোষী খুঁজলে মানুষ তথ্য লুকায়, আর প্রতিষ্ঠান শেখে না। Blameless postmortem-এ প্রশ্ন “কে ভুল করল?” নয়, বরং “সিস্টেম কীভাবে এমন ভুল সম্ভব করল, আর মানুষটি তখন কী জানতেন?” Google-এর ২০১৬ সালের “Site Reliability Engineering” বই এই চর্চাকে আরও ছড়িয়ে দেয়।

দোকানঘরে একটি কাল্পনিক উদাহরণ: সন্ধ্যায় এক ঘণ্টা কেউ অর্ডার দিতে পারেননি। Postmortem-এ লিখবেন — সময়রেখা (কখন শুরু, কখন জানলেন, কখন ঠিক হলো), কারণ (একটি migration একটি কলাম মুছেছিল যা পুরোনো process তখনো পড়ছিল), কেন আগে ধরা পড়েনি (টেস্টে পুরোনো আর নতুন সংস্করণ একসাথে চলেনি), আর পদক্ষেপ (migration দুই ধাপে: আগে যোগ, পরের deploy-এ মোছা; সতর্কবার্তা ৫ মিনিটে)।

মূল উপলব্ধি: সিস্টেম ভাঙবেই — পার্থক্য গড়ে কত দ্রুত জানতে পারেন, কত ভালো বুঝতে পারেন, আর ঘটনা থেকে দোষারোপ ছাড়া কতটা শেখেন।

অনুশীলন

চারটি কাজই দোকানঘরকে বাড়ানো। প্রথম দুটি অংশ ৫-এর Node ব্লকেই করে দেখা যায়; বাকি দুটি নিজের প্রজেক্টে।

  1. বাতিলে স্টক ফেরত: অর্ডার cancelled হলে তার পণ্যগুলোর স্টক ফেরত যাওয়া উচিত। changeStatus-এ এটি যোগ করুন। ইঙ্গিত: অর্ডারে items রাখা আছে; কাজটি transaction-এর ভেতরে করুন। কোন অবস্থা থেকে বাতিল হলে স্টক ফেরত দেওয়া ঠিক?

    changeStatus-এর শরীর transaction(() => { … })-এ মুড়ে দিন, আর o.status = to;-এর আগে: if (to === 'cancelled') for (const { productId, qty } of o.items) db.products.get(productId).stock += qty;. আমাদের NEXT টেবিলে বাতিল শুধু pending আর paid থেকে — দুটিতেই পণ্য দোকানে আছে, তাই স্টক ফেরত ঠিক। পরীক্ষা: একটি অর্ডার দিন, বাতিল করুন, তারপর GET /products-এ স্টক আগের মতো কি না দেখুন; আর বাতিল অর্ডার দ্বিতীয়বার বাতিলে 409 আসে কি না (না এলে স্টক দুবার ফিরত!)। আসল অ্যাপে: UPDATE products SET stock = stock + $1 … আর অবস্থার UPDATE একই transaction-এ।

  2. দুবার চাপলে দুটি অর্ডার নয় — idempotency key: দুর্বল নেটওয়ার্কে ক্রেতা উত্তর না পেয়ে আবার চাপলেন; প্রথম অনুরোধ কিন্তু পৌঁছেছিল। ব্রাউজার প্রতিটি চেকআউটের জন্য একটি এলোমেলো key পাঠাবে (Idempotency-Key header); একই key দ্বিতীয়বার এলে সার্ভার নতুন অর্ডার না বানিয়ে আগের উত্তরই ফেরত দেবে। কীভাবে বানাবেন?

    ব্রাউজারে: চেকআউট শুরুতে const key = crypto.randomUUID(), আর retry-তেও একই key. সার্ভারে: const seen = new Map(); route-এ const k = req.headers['idempotency-key']; if (seen.has(k)) return send(res, 200, seen.get(k));, অর্ডার সফল হলে seen.set(k, order). আসল অ্যাপে key রাখুন ডেটাবেসের একটি টেবিলে, UNIQUE constraint-সহ, আর অর্ডারের একই transaction-এ লিখুন — তাহলে একসাথে আসা দুটি অনুরোধেও একটিই সফল হয়। POST স্বভাবে idempotent নয়; এই কৌশলে তাকে নিরাপদে retry করার মতো বানানো হয় — পেমেন্ট API-গুলোতে এটি খুব প্রচলিত।

  3. SSE-র ছেঁড়া সংযোগে হারানো বার্তা: ক্রেতার ফোন ৩০ সেকেন্ড নেটওয়ার্কের বাইরে ছিল, এর মধ্যে অবস্থা paid হয়ে packed হলো। EventSource আবার জুড়লে কি তিনি সঠিক অবস্থা দেখবেন? আমাদের live.js পড়ে ভাবুন, আর Last-Event-ID কীভাবে কাজে লাগে খুঁজে দেখুন।

    আমাদের নকশায় সঠিক অবস্থাই দেখবেন, কারণ প্রতিটি নতুন সংযোগের শুরুতে route ডেটাবেস থেকে বর্তমান অবস্থা পাঠায় — মাঝের paid হারালেও ক্ষতি নেই, কারণ আমরা পাঠাই পুরো অবস্থা, পরিবর্তন নয়। এটি একটি ইচ্ছাকৃত নকশা। কিন্তু যদি প্রতিটি ঘটনাই জরুরি হয় (যেমন দোকানির ড্যাশবোর্ডে নতুন অর্ডারের তালিকা), তখন প্রতিটি বার্তায় ক্রমিক id: দিন (যেমন একটি events টেবিলের id). ব্রাউজার আবার জোড়ার সময় নিজে থেকে Last-Event-ID header পাঠায়; সার্ভার সেই id-র পরের সব ঘটনা ডেটাবেস থেকে পাঠিয়ে তারপর লাইভে যায়। আমাদের Date.now() id এ কাজে যথেষ্ট নয় — একই মিলিসেকেন্ডে দুটি ঘটনা হতে পারে।

  4. দোকানির ছোট ড্যাশবোর্ড: একটি GET /api/admin/summary বানান, যা আজকের মোট বিক্রি, অবস্থা অনুযায়ী অর্ডারের সংখ্যা আর ৫টির কম স্টকের পণ্য দেয় — শুধু লগইন করা দোকানির জন্য। SQL কেমন হবে, আর “আজ” কোন time zone-এ?

    তিনটি কোয়েরি: SELECT status, count(*) FROM orders GROUP BY status; SELECT coalesce(sum(total_paisa), 0) FROM orders WHERE status <> 'cancelled' AND created_at >= date_trunc('day', now() AT TIME ZONE 'Asia/Dhaka') AT TIME ZONE 'Asia/Dhaka'; আর SELECT id, title, stock FROM products WHERE stock < 5 ORDER BY stock. Time zone-এর কথাটি জরুরি: সার্ভার সাধারণত UTC-তে চলে, আর বাংলাদেশের মধ্যরাত UTC-র সন্ধ্যা ৬টা — time zone না দিলে “আজ” শুরু হবে ঢাকার সকাল ৬টায় — মাঝরাত থেকে ভোর ৬টার অর্ডার ভুল দিনের হিসাবে চলে যাবে (পর্ব ২০)। Route-এর সামনে requireLogin middleware, আর তিনটি কোয়েরি Promise.all-এ একসাথে (পর্ব ৩৩)।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি Node.js ওয়েব অ্যাপের CI/CD pipeline-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. git-এ push বা pull request
  2. npm ci দিয়ে lockfile মেনে dependency install
  3. ESLint আর Prettier দিয়ে lint
  4. unit, integration আর E2E টেস্ট
  5. production build তৈরি
  6. deploy, তারপর live ঠিকানায় smoke test

একটি অনলাইন দোকানে দুজন ক্রেতা একই মুহূর্তে শেষ পিসটি অর্ডার করলেন। কোন পদ্ধতিতে নিশ্চিতভাবে একজনই পাবেন?

  • আগে SELECT stock করে দেখা, যথেষ্ট থাকলে আলাদা UPDATE
  • একটি শর্তসহ UPDATE … SET stock = stock - 1 WHERE id = $1 AND stock >= 1, আর বদলানো সারির সংখ্যা পরীক্ষা
  • ব্রাউজারে স্টক শূন্য হলে বোতাম বন্ধ করে দেওয়া
  • Node.js single-threaded, তাই আলাদা কিছু করার দরকার নেই

লগইনের session id রাখার cookie-তে HttpOnly flag দেওয়ার মূল লাভ কী?

  • cookie শুধু HTTPS সংযোগে পাঠানো হয়
  • অন্য সাইট থেকে আসা অনুরোধে cookie যায় না
  • cookie ব্রাউজার বন্ধ করলেই মুছে যায়
  • পাতার JavaScript cookie-টি পড়তে পারে না, ফলে XSS হলেও চুরি কঠিন

সার্ভার থেকে ব্রাউজারে শুধু একমুখী লাইভ বার্তা (যেমন অর্ডারের অবস্থা) পাঠাতে Server-Sent Events-এর একটি সুবিধা কোনটি?

  • সাধারণ HTTP-তে চলে, আর ব্রাউজারের EventSource নিজে থেকে reconnect করে
  • ব্রাউজার একই সংযোগে সার্ভারে বার্তা পাঠাতে পারে
  • binary ডেটা পাঠানোর জন্য সবচেয়ে উপযুক্ত
  • সার্ভারে কোনো সংযোগ খোলা রাখতে হয় না

Vite-এ বানানো front end-এ import.meta.env.VITE_API_KEY হিসেবে একটি গোপন API key রাখা কেন ভুল?

  • Vite এমন variable পড়তেই পারে না
  • এতে পাতা ধীর হয়ে যায়
  • মানটি build-এ ব্রাউজারের JavaScript-এ বসে যায়, যা যে কেউ পড়তে পারে
  • .env ফাইল শুধু back end-এর জন্য তৈরি, front end তা দেখতে পায় না

nginx-কে একটি Node.js API-র সামনে reverse proxy হিসেবে রাখলে Express-এ সাধারণত app.set('trust proxy', 1) দেওয়া হয়। কেন?

  • Express-এর গতি বাড়াতে
  • যাতে X-Forwarded-For আর X-Forwarded-Proto থেকে ব্যবহারকারীর আসল IP আর HTTPS চেনে
  • CORS পরীক্ষা বন্ধ করতে
  • HTTPS certificate নিজে থেকে বানাতে

ডেটাবেস migration সম্পর্কে কোন কথাটি সঠিক?

  • production ডেটাবেসে হাতে ALTER TABLE চালানোই সবচেয়ে নিরাপদ
  • একবার চলা migration ফাইল পরে বদলে আবার চালানো যায়
  • migration থাকলে backup লাগে না
  • নম্বর দেওয়া ফাইল ক্রমে একবারই চলে, আর কোনটি চলেছে তা ডেটাবেসে লেখা থাকে

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. কোডের আগে user story, পরিধির সীমা, স্থাপত্য, ডেটা মডেল আর API চুক্তি — তারপর সাতটি মাইলস্টোন, প্রতিটির শেষে চলমান অ্যাপ আর “শেষ হয়েছে যখন…” যাচাই।
  2. Route পাতলা, service-এ ব্যবসার নিয়ম, repository-তে SQL; অর্ডার একটি transaction-এ, স্টক শর্তসহ UPDATE-এ, অবস্থা একটি কড়া state machine-এ।
  3. নিরাপত্তা ভিত থেকে: সার্ভারে যাচাই ও দাম, parameterized SQL, HttpOnly-Secure-SameSite cookie, hash করা পাসওয়ার্ড, CSP, secret শুধু environment-এ — আর front end আর API-র domain আগেই ঠিক করা।
  4. টেস্টের পিরামিড + CI: অনেক unit, কিছু integration (আসল DB), এক-দুটি E2E; প্রতিটি push-এ install → lint → test → build, main-এ deploy আর smoke test.
  5. Live হওয়া শুরু মাত্র: structured log, error-এর সতর্কতা, পরীক্ষা করা backup, নিয়মিত হালনাগাদ — আর portfolio-তে README, live লিংক ও সৎ ব্যাখ্যা; AI সহকারী, মালিক আপনি।

পরের পর্বে: Module ৮ শুরু — পরীক্ষা ও ইন্টারভিউ প্রস্তুতি। প্রথমে কোড পড়ার অনুশীলন: ছোট ছোট JavaScript কোড দেখে আউটপুট কী হবে, চালানোর আগেই বলতে পারেন কি? coercion, closure, event loop — সব আবার, এবার প্রশ্নের আকারে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান