iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Modern Web

現在就學Node.js系列 第 10

用HTTP、fs與path模組 — 打造靜態檔案伺服器和API - Day10

  • 分享至 

  • xImage
  •  

已經來到第十天了,我們已經學習不少Node.js的相關知識與概念,

今天要整合以下幾個模組:

  • path 模組 → 處理路徑、避免路徑錯誤
  • fs 模組 → 讀寫檔案
  • HTTP 模組 → 建立伺服器

把這些模組串起來,做出一個「簡易的網站伺服器」,有以下功能:

  1. 靜態檔案伺服器 → 提供 HTML、CSS、圖片等資源
  2. 簡易 API → 能用 GET 讀取資料、POST 新增資料
  3. 前端互動頁面 → 使用者可以在頁面上操作 API

透過這樣的練習就能明白,Node.js是如何扮演「網站伺服器 + API 提供者」的角色,以及過程是如何運作的。

專案結構

project/
├─ public/
│  ├─ index.html
│  ├─ style.css
│  └─ logo.jpg
└─ server.js
  • public/ → 放所有靜態檔案
  • server.js → Node.js 伺服器程式

完整伺服器程式碼

import http from "node:http";
import { promises as fsp } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, extname, resolve } from "node:path";

// ESM 環境模擬 __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const PUBLIC_DIR = join(__dirname, "public");

// 假資料庫
let notes = [
  { id: 1, title: "第一則筆記" },
  { id: 2, title: "第二則筆記" }
];

// 常見 MIME 類型
const MIME = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".svg": "image/svg+xml",
};

// 讀取並回應檔案
async function serveStatic(pathname, res) {
  const target = pathname === "/" ? "/index.html" : decodeURIComponent(pathname);
  const filePath = resolve(join(PUBLIC_DIR, "." + target));

  // 防止目錄穿越攻擊
  if (!filePath.startsWith(PUBLIC_DIR)) {
    res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
    return res.end("Bad Request");
  }

  try {
    const data = await fsp.readFile(filePath);
    const ext = extname(filePath);
    res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
    res.end(data);
  } catch {
    res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
    res.end("❌ Not Found");
  }
}

// 建立伺服器
const server = http.createServer(async (req, res) => {
  if (req.url.startsWith("/api/notes")) {
    // API:取得所有筆記
    if (req.method === "GET") {
      res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
      res.end(JSON.stringify(notes));
    }
    // API:新增筆記
    else if (req.method === "POST") {
      let body = "";
      req.on("data", chunk => (body += chunk));
      req.on("end", () => {
        const newNote = { id: Date.now(), title: JSON.parse(body).title };
        notes.push(newNote);
        res.writeHead(201, { "Content-Type": "application/json; charset=utf-8" });
        res.end(JSON.stringify(newNote));
      });
    } else {
      res.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
      res.end("Method Not Allowed");
    }
  } else {
    // 靜態檔案處理
    serveStatic(req.url, res);
  }
});

server.listen(3000, () => {
  console.log("🚀 靜態伺服器運行中:http://localhost:3000");
});

前端檔案

index.html

<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <title>Node.js 靜態伺服器 + API</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>Hello Node.js 🚀</h1>
  <p>這是一個結合靜態檔案 & API 的伺服器。</p>

  <h2>📒 筆記列表</h2>
  <ul id="notes"></ul>

  <h2>✍️ 新增筆記</h2>
  <input type="text" id="noteInput" placeholder="輸入筆記內容">
  <button id="addBtn">新增</button>

  <script>
    const notesList = document.getElementById("notes");
    const noteInput = document.getElementById("noteInput");
    const addBtn = document.getElementById("addBtn");

    // 取得所有筆記 (GET)
    async function fetchNotes() {
      const res = await fetch("/api/notes");
      const data = await res.json();
      notesList.innerHTML = "";
      data.forEach(note => {
        const li = document.createElement("li");
        li.textContent = note.title;
        notesList.appendChild(li);
      });
    }

    // 新增筆記 (POST)
    async function addNote() {
      const title = noteInput.value.trim();
      if (!title) return alert("請輸入內容");

      await fetch("/api/notes", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title })
      });

      noteInput.value = "";
      fetchNotes(); // 新增後重新載入
    }

    addBtn.addEventListener("click", addNote);

    // 頁面載入時執行
    fetchNotes();
  </script>
</body>
</html>

style.css

body {
  font-family: sans-serif;
  background: #eef7ff;
  text-align: center;
  padding: 50px;
}

h1 {
  color: #3c873a;
}

li {
  margin: 5px 0;
  font-size: 16px;
}

為什麼要用 path.resolve + startsWith

假設有人輸入:

http://部屬網址/../../etc/passwd

伺服器可能會「跑出去」讀取敏感檔案。

這是 目錄穿越攻擊 (Directory Traversal)

因此要用 resolve()startsWith(),確保只能存取 public/ 目錄內的檔案,避免資安漏洞。

運行專案

把檔案都設定好後,只要在 終端機上 輸入 node server.js 後,就會啟動伺服器了

會看到有一行 靜態伺服器運行中:http://localhost:3000 ,表示有成功運行!

打開後會看到這樣的畫面

範例介面

成功順利的把靜態資源都放到網頁上了!

同時,也順利的成功用 GET 拿到 notes 的資料,並渲染到頁面上。

上面的範例圖,用開發者工具看會發現到有四個請求資源,
我們來看它請求資源的過程

[Client]
  │
  ├── GET  /index.html → 靜態檔案
  ├── GET  /style.css  → 靜態檔案
  ├── GET  /node.svg   → 靜態檔案
  ├── GET  /api/notes  → API (回傳 JSON)
  └── 尚未發送 POST /api/notes → API (新增筆記)  
  │
  ▼
[Node.js Server]
  │ http + fs + path
  │
  ▼
[Response]
  │
  │ HTML / CSS / JSON / 圖片

接下來,就換你動手做看看囉~

小結

今天我們完成了一個:

  1. 靜態檔案伺服器 → 提供 HTML/CSS/圖片
  2. 簡易 API (/api/notes) → 讀取 & 新增筆記
  3. 前端整合 → 瀏覽器能發 GET / POST,並即時更新畫面

上一篇
HTTP 模組實戰 — 打造迷你伺服器 - Day9
下一篇
Express.js 入門 - Day11
系列文
現在就學Node.js11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言