【Node.js実戦習得】第7回:標準httpモジュールで自作する超シンプルWebサーバー(TS版)

社員ブログ

1. Webサーバーの正体と「HTTPプロトコル」の深層構造

酒本先輩、前回 tsconfig.jsontypes: ["node"] を設定したら、無事に npx tsc の本番ビルドが通るようになりました!

環境が完璧になったので、いよいよWebサーバーを作ってみたいです!

トラブルを自力で乗り越えた後のコーディングは最高よ!

じゃあ質問だけど、熊木くんがブラウザでWebサイトを見たり、フロントのコードからAPIを叩くとき、裏では何が行われていると思う?

えっと……URLを指定して、サーバーに『このデータをください!』っておねがい(リクエスト)して、サーバーから『はいどうぞ!』ってお返事(レスポンス)が返ってきます

大正解。

その『おねがい』と『お返事』のときの世界共通のルールのことを『HTTP(HyperText Transfer Protocol)』と呼ぶの。

Webサーバーの正体とは、一言で言えば『特定のポート(門番)でずーーーっと待ち構えていて、HTTPルールに則ったリクエストが来たら、HTTPルールに則ったレスポンスを返すプログラム』のことよ。

私たちがこれから解析していくHTTPリクエストは、パケットレベルで見ると、大きく分けて以下の3つの階層構造で構成されているわ。

  1. スタートライン(リクエストライン): 「どのURL(パス)」に「どの方法(HTTPメソッド:GETやPOSTなど)」でアクセスするかという、通信の目的。
  2. ヘッダー(Headers): クライアントのブラウザ環境(User-Agent)、受け入れ可能なデータ形式(Accept)、送信するデータの種類(Content-Type)などの、メタ情報の塊。
  3. ボディ(Body): 新しくカートに追加したい商品情報など、具体的なデータの中身(※GETリクエストのときは基本的に空)。

Node.jsには、この待ち構える仕組みと通信ルールを簡単に扱える node:http モジュールが標準で備わっているの。

今回は、実務でも頻出する『ECサイトのカート機能』を題材にして、まずはフレームワークなしで組み立ててみましょう!

2. 【ハンズオン】httpモジュールでカートAPIを起動する(データ取得編)

さあ、さっそく手を動かしましょう!

src/index.ts の中身を全部消して、Node.js標準の node:http を使った、カート内商品の一覧取得(GET)処理を書いてみて!

// src/index.ts
import { createServer, IncomingMessage, ServerResponse } from 'node:http';

const PORT = 3000;

// カート内商品の型定義
interface CartItem {
  id: number;
  productName: string;
  price: number;
  quantity: number;
}

// 疑似データベース(メモリ上に保持する配列)
let cartItems: CartItem[] = [
  { id: 1, productName: '熊木用高級キーボード', price: 35000, quantity: 1 },
  { id: 2, productName: 'Type-C 急速充電ケーブル 1.5m', price: 1800, quantity: 2 }
];

// 1. HTTPサーバーインスタンスを作成
// createServerの引数には、リクエストを検知したときに実行されるコールバック関数を渡す
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
  const { method, url } = req;
  // サーバーのログとして、いつ、どのメソッドで、どこにアクセスが来たかを出力
  console.log(`[${new Date().toISOString()}] ${method} ${url}`);

  // 2. ルーティングの分岐:URLが「/api/cart」かつ メソッドが「GET」の場合
  if (url === '/api/cart' && method === 'GET') {
    // レスポンスヘッダーに「JSONを返しますよ」というContent-Typeを指定(日本語が化けないようにutf-8も必須!)
    res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });

    // 配列オブジェクトをJSON文字列に変換して送信し、通信を終了(end)する
    res.end(JSON.stringify(cartItems));
    return;
  }

  // 3. 該当するルーティングがない場合(404 Not Found)
  res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify({ message: 'リクエストされたリソースが見つかりませんでした。' }));
});

// 4. 指定したポート番号でサーバーを起動(リスニング開始)
server.listen(PORT, () => {
  console.log(`標準モジュール・カートサーバーが起動しました! http://localhost:${PORT}`);
});

書けました!

npm run dev で起動してみます!

npm run dev

実行結果(ターミナル)

標準モジュール・カートサーバーが起動しました! http://localhost:3000

あ、プログラムが終了せずに待機状態になっています。

『OSからの通信イベントを待っている状態』ですね!

さっそく curl でアクセスしてみます!

curl -i http://localhost:3000/api/cart

実行結果(ターミナル)

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Mon, 10 Aug 2026 12:00:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 172

[{"id":1,"productName":"熊木用高級キーボード","price":35000,"quantity":1},{"id":2,"productName":"Type-C 急速充電ケーブル 1.5m","price":1800,"quantity":2}]

すごい!

-i オプションで HTTP ヘッダーと一緒に綺麗な JSON が返ってきました!

自分が書いたTSコードが、ネットワーク越しにデータを返している実感が湧きます!

3. 低レイヤの死闘:POSTリクエストと「ストリーム」の罠

データの取得(GET)は簡単でしたね!

じゃあ次は、フロントエンドから新しく商品をカートに入れる『商品追加(POST)』機能を追加したいです。

req.body みたいに書けばデータが取れるんですか?

甘いな熊木くん。

そこが標準モジュール最大の難所であり、低レイヤの洗礼が待っている場所よ。
実は、標準モジュールの req(IncomingMessage)には、req.body なんていう便利なプロパティは存在しないの。

ネットワークから送られてくるデータは、一気にドカンと届くわけじゃないのよ

 

インターネット通信では、データは細切れのバイナリデータ(チャンク)に分解されて流れてくるの。

Node.jsは、このデータを受け取るために「ストリーム(Stream)」というイベント駆動の仕組みを使っているわ。
つまり、サーバー側では「データが少しずつ届くたびに結合し、すべて届き終わったらようやく中身を解析する」という、面倒な非同期処理を自前で書かなきゃいけないのよ。

百聞は一見に乗るわね。

今のコードのなかに、POST /api/cart の処理を組み込んでみましょう!

ネットワークの生データをどう結合するか、じっくり見てみて!

/* 先ほどの createServer の中に以下を追加 */

  // 【POST /api/cart】 カートへの商品追加
  if (url === '/api/cart' && method === 'POST') {
    let body = '';

    // データの断片(chunk)が届くたびに、文字列として結合していく
    req.on('data', (chunk) => {
      body += chunk;
    });

    // すべてのデータが届き終わったら(end)実行されるイベント
    req.on('end', () => {
      try {
        // 届いた生の文字列を、手動でJSONオブジェクトにパース(解析)する
        const parsedData = JSON.parse(body);
        const { productName, price, quantity } = parsedData;

        // バリデーション(型チェックと必須チェック)
        if (!productName || typeof price !== 'number' || typeof quantity !== 'number') {
          res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
          res.end(JSON.stringify({ message: '不正なリクエストデータです。商品名、価格、数量を正しく入力してください。' }));
          return;
        }

        // 数量が0以下、または価格がマイナスの不正チェック
        if (quantity <= 0 || price < 0) {
          res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
          res.end(JSON.stringify({ message: '価格は0以上、数量は1以上である必要があります。' }));
          return;
        }

        // 新しいカートアイテムオブジェクトを作成(IDの自動採番)
        const newItem: CartItem = {
          id: cartItems.length > 0 ? Math.max(...cartItems.map(item => item.id)) + 1 : 1,
          productName,
          price,
          quantity
        };

        cartItems.push(newItem);

        // 201 Created で新しく追加されたアイテムを返却
        res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8' });
        res.end(JSON.stringify(newItem));
      } catch (error) {
        // JSONの構文エラー(カンマが抜けているなど)が起きた場合の例外ハンドリング
        res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
        res.end(JSON.stringify({ message: '不正なJSON形式です。パースに失敗しました。' }));
      }
    });
    return;
  }

カートに商品を追加するだけなのに、req.on('data')req.on('end') というイベントリスナーを登録して、コールバックの中でパースして、さらに手動で try-catch まで……。

フロントエンドの axios.post(url, data) がいかに裏側の面倒なことを隠してくれていたかがよく分かります

これが『生のNode.js』の実態。

でも、これを書いておくことで、ネットワークからデータがどうストリーミング処理され、サーバーのメモリに蓄積されているかが完璧に脳内補完できるようになるわ。

この『データの分割受信』の感覚はすごく重要なのよ。この面倒な処理が様々なもので動いているって覚えておいてね。

4. カートAPIの動作確認:curlでのシミュレーション

コードが書けたら、実際にフロントエンドからリクエストが飛んできたと仮定して、ターミナルから curl コマンドでテストしてみましょう!

# 別のターミナルを立ち上げるか、現在のプロセスを止めずに以下のコマンドを実行
curl -i -X POST -H "Content-Type: application/json" -d '{"productName":"AI機能搭載Webカメラ","price":10800,"quantity":1}' http://localhost:3000/api/cart

実行結果(新規作成成功・201 Created)

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Mon, 10 Aug 2026 12:01:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 82

{"id":3,"productName":"AI機能搭載Webカメラ","price":10800,"quantity":1}

あっ!ステータスコード 201 Created と一緒に、新しいID 3 が割り振られて返ってきました!

curl -i http://localhost:3000/api/cart で一覧を再取得してみると、カート内が3件に増えています!

いいわね。

じゃあ、わざとデータを壊して、"price": "一万円" みたいに文字列にして送ってみて?

curl -i -X POST -H "Content-Type: application/json" -d '{"productName":"不正なデータ","price":"一万円","quantity":1}' http://localhost:3000/api/cart

実行結果(バリデーション発動・400 Bad Request)

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
Date: Mon, 10 Aug 2026 12:02:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Content-Length: 114

{"message":"不正なリクエストデータです。商品名、価格、数量を正しく入力してください。"}

自分で書いたバリデーションがちゃんと機能して、サーバーが不正なデータを弾いてくれました!

エラーの時、ただ落ちるんじゃなくて、適切なHTTPステータスコード 400 を設定してエラー用のJSONを返すことの大切さが分かります

5. 実務での限界:なぜ標準モジュールだけで開発を続けないのか?

仕組みはすごくよく分かりました!

カートAPI、結構楽しいです。

じゃあ次は、カートから特定のアイテムを削除する『削除(DELETE)』機能や、数量を変更する『更新(PUT)』を実装しようと思うのですが

いいね!

でも、ここで標準モジュールだけだと限界があるわ。
もし『カート内のID: 2 の商品を削除する』というAPIを作りたい場合、REST APIの設計ルールだと、URLは DELETE /api/cart/2 のようになるのが一般的よ。
さあ、今の標準の req.url から、どうやってこの 2 という数字を取り出す?

えっ……?

url === '/api/cart' みたいな完全一致のif文が使えないですね。

ええと、文字列を / で分割(split)するか、正規表現を使って末尾の数字を切り出す……?

その通り。

URLを毎回文字列解析(パース)して、数字を切り出して、それが数値かどうかチェックして……という面倒なコードを、すべてのAPIごとに自前で書かなきゃいけなくなるわ。
今回はURLが /api/cart だけだからいいけれど、実際のECサイトなら /api/products(商品一覧)、/api/orders(注文処理)、/api/users(ユーザー設定)って、URLが50個、100個と増えていくわよね。

さらにクエリパラメータ(?sort=price_asc&limit=10)が混ざってきたらどうなると思う?

かなりきついですね……。

index.ts が、不格好なif文のネストと正規表現の文字列解析の嵐で、開発不能な破綻コード(スパゲッティコード)になりますね

だから実務ではこの『低レイヤ処理』を綺麗に包み込んで、直感的なコードに抽象化してくれるWebフレームワーク(Expressなど)が必要不可欠になるわ。
標準モジュールの面倒くささを知った熊木くんなら、次回導入するフレームワークの圧倒的なありがたみが分かるわね!

本日のまとめ

  • Webサーバーの本質: 特定のポートで待機し、HTTP通信ルール(リクエスト)に基づいて、適切なルール(レスポンス)を返却し続けるプログラム。
  • node:http モジュール: Node.js標準のコアモジュール。外部ライブラリなしでネイティブなWebサーバーを構築できる。
  • ストリームの概念: POSTなどで送られてくるデータは一括ではなく、細切れ(チャンク)のバイナリで届くため、req.on('data') で結合する処理が必要。
  • エラーハンドリングの重要性: データの欠損やJSONの構文エラーに対し、適切なステータスコード(400等)とエラーメッセージを返却しないと、フロントエンド側が原因を特定できなくなる。
  • 標準モジュールの限界: URLやメソッドの条件分岐(ルーティング)やパラメータの解析をすべて自前で書く必要があり、規模が大きくなるとコードが破綻する。

次回:第8回「Expressフレームワーク入門!ECカートAPIの移行と型安全ルーティング」に続く

タイトルとURLをコピーしました