手写支持静态文件与路由的 HTTP 服务器

本节目标

最小服务器:http.createServer 每来一个请求调用一次回调,我们在回调里读 URL、组织响应。

// 运行环境:Node.js 14+
// 保存为 aj-l3.js,执行:node aj-l3.js,浏览器访问 http://localhost:3000/
const http = require('http')
const fs = require('fs')
const path = require('path')

const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json' }

const server = http.createServer((req, res) => {
  // 路由:API 与静态文件分流
  if (req.url === '/api/hello') {
    res.writeHead(200, { 'Content-Type': 'application/json' })
    return res.end(JSON.stringify({ msg: 'hello from handmade server' }))
  }
  // 静态文件:映射到 ./public 目录(防目录穿越)
  const safe = path.normalize(req.url).replace(/^(\.\.[/\\])+/, '')
  const file = path.join(__dirname, 'public', safe === '/' ? 'index.html' : safe)
  fs.readFile(file, (err, data) => {
    if (err) { res.writeHead(404); return res.end('Not Found') }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' })
    res.end(data)
  })
})
server.listen(3000, () => console.log('server on http://localhost:3000'))

// 调用示例(另开一个终端用 curl 测试):
//   curl http://localhost:3000/api/hello
//   curl http://localhost:3000/          (需有 public/index.html)

关键点:res.writeHead 写状态码与响应头,res.end(data) 结束响应并发送 body。路由本质就是"根据 req.url/req.method 分发到不同处理函数"——这正是 Express/Koa 路由的核心。

名词解释

课后练习

  1. 为什么静态服务必须做"目录穿越"防护?
    • 答案:若不处理 ../,用户请求 /../../etc/passwd 可能读到系统敏感文件;path.normalize 解析后剥掉 ../ 前缀,把所有路径锁在 public 内。
  2. res.end 和 res.write 有什么区别?
    • 答案:end 发送数据并结束响应(只能调一次);write 可分多次发送数据但响应仍打开,最后仍需 end 关闭。

总结

手写一个 HTTP 服务器,是前端工程师"向上够一够后端"性价比最高的一课。你会发现 Express、Koa、甚至 Nest,本质上都是 http.createServer 回调里那套"读 URL → 分发处理函数 → 组织响应"的封装。理解了这条主线,你就不会被框架的"中间件""路由表"绕晕——它们只是让分发更优雅、让错误处理更统一。我特别想提醒两个工程常识:第一,MIME 类型写错会让浏览器把 JS 当文本、把 JSON 当下载,看似玄学实则低级错误;第二,任何文件服务都必须堵死目录穿越,这是底线安全。亲手跑通这个服务器,你对"前后端边界"的理解会从"调接口"升级到"我也能造接口"。