ข้ามไปยังเนื้อหา

การสร้าง HTTP Server

http.createServer(callback) คืนค่า Server instance โดย callback — เรียกว่า request listener — จะถูกเรียกทุกครั้งที่มี HTTP request เข้ามา โดยรับ argument สองตัวคือ:

  • req — object IncomingMessage ที่อธิบาย request (method, URL, headers, body stream)
  • res — object ServerResponse ที่ใช้เขียน response

เรียก server.listen(port, callback) เพื่อเริ่มรับ connection โดย callback จะทำงานเมื่อ server พร้อมแล้ว

HTTP response ทุกอันมี status code และชุด headers ใช้ res.writeHead(statusCode, headersObject) เพื่อกำหนดทั้งสองอย่างพร้อมกันก่อนที่จะเขียน body ใด ๆ

status codes ที่ใช้บ่อย:

Codeความหมาย
200OK
201Created
400Bad Request
404Not Found
500Internal Server Error

res.end(data) เขียน chunk สุดท้ายของ response body และบอก Node.js ว่า response เสร็จสมบูรณ์แล้ว ทุก request handler ต้อง เรียก res.end() (หรือ res.destroy()) — ถ้าลืม client จะรอไปตลอดและ connection จะ timeout

คุณยังสามารถใช้ res.write(data) เพื่อส่งข้อมูลเป็น chunk ก่อนที่จะเรียก res.end() ได้

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

เปิดใน StackBlitz เพื่อดูมันทำงาน — server จะ request ตัวเองหนึ่งครั้ง พิมพ์ HTML response ที่ render ออกมาพร้อม headers ที่ถูกต้อง แล้วปิดตัวเอง

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

req, res และตัว Server เองล้วนเป็น EventEmitter และ event 'error' นั้นพิเศษ: ถ้าไม่มีใครฟัง Node.js จะ throw มัน — ซึ่งทำให้ process ทั้งตัว crash ไม่ใช่แค่ request เดียว การเรียก res.end() ครั้งที่สองเป็นวิธีทั่วไปที่ทำให้เกิดสิ่งนี้พอดี: การเรียกครั้งที่สองจะล้มเหลวแบบ asynchronous ด้วย ERR_STREAM_WRITE_AFTER_END และเมื่อไม่มี listener error นั้นก็ไม่มีที่ไปนอกจากขึ้นไปที่ process:

Node.js
Output (verified on Node 24)
handling request - about to call res.end() twice

การใส่ 'error' listener ให้ res เปลี่ยน crash นั้นให้กลายเป็น event ธรรมดาที่ถูกจัดการแล้ว — process รอดต่อไป และคุณเป็นคนตัดสินใจว่าจะทำอะไรต่อ (log มัน, ปิด connection, แจ้งเตือน):

Node.js
Output (verified on Node 24)
res error handled instead of crashing: ERR_STREAM_WRITE_AFTER_END
client received: first response

error listener เป็นแค่ safety net ไม่ใช่ทางแก้จริง ทางแก้จริงคือไม่เรียก res.end() สองครั้งตั้งแต่แรก — early return ทันทีหลังการเรียกครั้งแรกรับประกันสิ่งนั้นได้:

function handleRequest(req, res, hasError) {
if (hasError) {
res.writeHead(400);
res.end('bad request');
return; // early return — nothing below this line runs for the error case
}
res.writeHead(200);
res.end('ok');
}
// Simulate a fake res object just to prove only one end() call happens per branch
let calls = 0;
const fakeRes = { writeHead() {}, end() { calls++; } };
handleRequest(null, fakeRes, true);
handleRequest(null, fakeRes, false);
console.log('res.end() was called', calls, 'times for 2 requests (never more than once each)');

เมื่อ headers ถูกส่งไปแล้ว — ด้วย res.writeHead() หรือด้วยการเรียก res.write()/res.end() ครั้งแรก — มันเปลี่ยนไม่ได้อีก การพยายามเปลี่ยนจะ throw ERR_HTTP_HEADERS_SENT แบบ synchronous ซึ่งทำให้ catch ได้ง่าย ต่างจาก crash แบบ asynchronous ด้านบน:

Node.js
Output (verified on Node 24)
caught: ERR_HTTP_HEADERS_SENT
client received: done

เมื่อ handler เรียก res.end(data) เพียงครั้งเดียว Node.js รู้ขนาด body ทั้งหมดล่วงหน้าและตั้ง Content-Length ให้คุณ เมื่อ handler เรียก res.write() มากกว่าหนึ่งครั้งก่อน res.end() Node.js ไม่สามารถรู้ขนาดรวมล่วงหน้าได้ จึงเปลี่ยนไปใช้ Transfer-Encoding: chunked แทน — ไม่มี header Content-Length เลย:

Node.js
Output (verified on Node 24)
/fixed Content-Length: 5 Transfer-Encoding: null
/chunked Content-Length: null Transfer-Encoding: chunked

Content-Length ต้องเป็นจำนวน byte ที่แน่นอน ไม่ใช่จำนวนตัวอักษร — สำหรับข้อความ ASCII ล้วน ๆ ตัวเลขสองอย่างนี้เท่ากัน แต่สำหรับข้อความอื่น (ภาษาไทย, emoji) มันไม่เท่ากัน Buffer.byteLength(str) ให้จำนวน byte จริง ส่วน .length ของ JavaScript string เองนับ UTF-16 code unit แทน:

const text = 'สวัสดี'; // Thai greeting - each character is multiple UTF-8 bytes
console.log('.length (UTF-16 code units):', text.length);
console.log('Buffer.byteLength (actual UTF-8 bytes):', Buffer.byteLength(text));

ถ้าคุณเคยตั้ง Content-Length เองแทนที่จะให้ res.end(data) คำนวณให้ (เช่นตอน stream body ผ่านหลาย write แต่ยังต้องประกาศความยาวล่วงหน้า) ให้ใช้ Buffer.byteLength เสมอ ไม่ใช่ .length — การใช้ .length กับข้อความที่ไม่ใช่ ASCII จะประกาศจำนวน byte น้อยกว่าที่ส่งจริง และ client จะตัด body ทิ้งที่ความยาวที่ประกาศไว้

โดย default Node.js จะเก็บ TCP connection เปิดไว้หลัง response เพื่อให้ client ส่ง request ถัดไปได้โดยไม่ต้อง handshake ใหม่ (Connection: keep-alive) มี timeout อิสระสามตัว แต่ละตัวมี default ของตัวเอง กำหนดว่า server จะรอนานแค่ไหน:

Node.js
Output (verified on Node 24)
headersTimeout: 60000
requestTimeout: 300000
keepAliveTimeout: 5000
TimeoutDefaultขอบเขต
headersTimeout60,000 msNode รอ header ของ request มาครบได้นานแค่ไหน
requestTimeout300,000 msทั้ง request (header + body) ใช้เวลาได้นานแค่ไหน
keepAliveTimeout5,000 mskeep-alive connection ที่ว่างอยู่จะเปิดค้างรอ request ถัดไปได้นานแค่ไหน

ทั้งสามตัวเป็น property ธรรมดาที่คุณ reassign บน server object ได้ก่อนที่มันจะเริ่ม listen เมื่อคุณต้องการปิด socket ที่เปิดอยู่ทั้งหมดทันที — ระหว่าง test ในโมดูลนี้ หรือระหว่าง shutdown ที่รอมานานพอแล้ว — server.closeAllConnections() จะบังคับปิดทุก connection ไม่ว่าจะ keep-alive หรือไม่ แทนที่จะรอ timeout พวกนี้จนหมดเวลา

การพยายาม listen() บน port ที่ process อื่นใช้อยู่แล้วจะไม่ throw แบบ synchronous — มันจะ emit event 'error' บน server พร้อม code ที่คุณตรวจสอบได้:

Node.js
Output (verified on Node 24)
second listen failed: EADDRINUSE

นี่คือกฎเดียวกับด้านบนของ section นี้: server.on('error', ...) คือสิ่งที่กั้นระหว่าง port ที่ถูกใช้แล้วกับ crash แบบ unhandled-exception ตอน startup

process manager (Docker, Kubernetes, systemd) จะส่ง SIGTERM เมื่อต้องการให้ server ของคุณหยุด การปิดทันทีจะทิ้ง request ที่กำลังทำอยู่ ส่วนการปิดแบบ graceful จะหยุดรับ connection ใหม่แต่ปล่อยให้ request ที่กำลังทำอยู่เสร็จก่อน:

flowchart TD
A["Process receives SIGTERM"] --> B["server.close() - stop accepting new connections"]
B --> C{"Any in-flight requests?"}
C -->|yes| D["Wait for each in-flight response to finish"]
D --> C
C -->|no| E["close callback fires - process can exit"]
B -.->|timeout fallback| F["closeAllConnections() force-ends stragglers"]
ลำดับของ graceful shutdown
Node.js
Output (verified on Node 24)
SIGTERM received, draining in-flight requests: 1
in-flight requests remaining: 0
client got: slow response done
server closed - all in-flight requests finished first

server.close() เพียงอย่างเดียวอาจค้างตลอดไปได้ถ้า client ถือ keep-alive connection ไว้โดยไม่มีอะไรทำอยู่ — โค้ด production จริงจะคู่มันกับ timeout fallback ที่เรียก server.closeAllConnections() หลังผ่านช่วงเวลาผ่อนผันที่กำหนดไว้ เพื่อให้ shutdown ที่ใช้เวลานานเกินไปยังจบได้ บทเรียน Production Server ท้ายโมดูลนี้จะต่อ fallback นั้นเข้าไปด้วย

Node.js ยังมี node:http2 ซึ่งมี compatibility API ที่หน้าตาแทบเหมือน node:http เป๊ะ — createServer, request listener, res.writeHead/res.end มันรันบน plaintext HTTP/2 (h2c) ซึ่งเป็นสิ่งที่ demo ในโปรเซสเดียวกันพูดได้โดยไม่ต้องมี TLS certificate:

Node.js
Output (verified on Node 24)
received over http/2: hello over h2c

server HTTP/2 ในโลกจริงเกือบทั้งหมดรันบน TLS (http2.createSecureServer) เพราะ browser หลักทุกตัวพูด HTTP/2 แบบนั้นเท่านั้น — โหมด plaintext h2c ด้านบนมีไว้สำหรับ traffic ระหว่าง service ภายในและ demo แบบนี้เป็นหลัก

ทุก response คือสัญญาที่มีขั้นตอนบังคับสองขั้นตอน: ส่ง headers ครั้งเดียว (writeHead หรือโดยนัยตอน write/end ครั้งแรก) แล้วส่ง body และปิดมันครั้งเดียว (end) ทุกอย่างใน section นี้คือความหลากหลายของ “จะเกิดอะไรขึ้นเมื่อสัญญานั้นถูกทำผิด”: ทำผิดโดยเรียกขั้นตอนหนึ่งสองครั้ง (ERR_HTTP_HEADERS_SENT, crash จาก double-end()) ทำผิดโดยไม่เคยทำให้เสร็จ (client ที่ค้าง, timeout ต่าง ๆ) หรือทำผิดจากสิ่งที่อยู่นอก handler ของคุณไปเลย (EADDRINUSE ก่อนที่ request จะมาถึงด้วยซ้ำ) 'error' listener ไม่ได้ป้องกันสิ่งเหล่านี้เลย — มันแค่ตัดสินว่าการทำผิดสัญญาจะทำให้ request เดียว down หรือทั้ง process down

การจัดการทุก error path ที่แสดงในบทนี้ — res.on('error'), try/catch รอบ writeHead, guard สำหรับ EADDRINUSE, handler สำหรับ graceful-shutdown — คือโค้ดจริงที่คุณต้องเขียนและดูแลสำหรับทุก server และส่วนใหญ่ไม่เคยทำงานเลยตอนใช้งานปกติ การข้ามมันไม่มีค่าใช้จ่ายอะไรจนกว่าจะถึงครั้งที่ client ตัดการเชื่อมต่อกลาง response หรือ deploy ส่ง SIGTERM กลาง request ซึ่งตอนนั้นค่าใช้จ่ายคือ crash แบบ unhandled หรือ request ที่หายไปใน production ไม่มีของฟรีตรงนี้: framework ฝัง default ที่สมเหตุสมผลสำหรับกรณีพวกนี้ไว้แล้ว ซึ่งเป็นส่วนใหญ่ของสิ่งที่คุณกำลังจ่ายเงินซื้อจริง ๆ เมื่อเพิ่ม framework เข้ามา

  • “error ข้างใน request handler กระทบแค่ request นั้นตัวเดียว” จะจริงก็ต่อเมื่อมีอะไรฟังมันอยู่ event 'error' ที่ไม่มีคน handle บน req, res หรือตัว server เอง จะ throw และทำให้ ทั้ง process crash — ทุก request ที่กำลังทำอยู่ล้มไปด้วยกันหมด
  • “ERR_HTTP_HEADERS_SENT กับ crash จาก double-res.end() คือ bug เดียวกัน” มันเกี่ยวข้องกันแต่ต่างกัน: การเขียน headers/body ซ้ำจะ throw แบบ synchronous และ catch ได้ง่าย ๆ ส่วนการเรียก res.end() สองครั้งจะล้มเหลวแบบ asynchronous ในฐานะ stream error ซึ่งต้องใช้ 'error' listener ไม่ใช่ try/catch เพื่อไม่ให้ crash
  • “Content-Length ก็แค่ str.length” จริงเฉพาะข้อความ ASCII ล้วน ๆ ตัวอักษร multi-byte ใด ๆ (ไทย, emoji, ภาษาที่ไม่ใช่อังกฤษส่วนใหญ่) ทำให้ .length (UTF-16 code unit) น้อยกว่าจำนวน byte จริง — ใช้ Buffer.byteLength เสมอสำหรับความยาวที่คุณประกาศเอง

โค้ด handler ที่ AI เขียนมักจะจบทุก branch ของ if/else ด้วย res.end() ของตัวเอง ซึ่งดูปลอดภัย — จนกระทั่งมีการแก้ไขทีหลังเพิ่ม code path หลัง block if/else ที่สมมติว่า response ยังไม่ถูกส่ง แล้ว res.end() ก็ถูกเรียกเป็นครั้งที่สอง

🐛 หา bug ในโค้ดนี้

function handleRequest(req, res, isAdmin) {
if (isAdmin) {
res.writeHead(200);
res.end('admin dashboard');
} else {
res.writeHead(403);
res.end('forbidden');
}
// Added later: log every request after handling it
console.log('handled', req.url);
if (req.url === '/admin/audit') {
res.writeHead(200);
res.end('audit log'); // BUG: res.end() was already called above for this same request
}
}
เฉลย

ทั้งสอง branch ของ if/else เดิมเรียก res.end() ถูกต้องแค่ครั้งเดียว — bug อยู่ที่โค้ดที่เพิ่มมาทีหลัง ซึ่งสมมติว่าปลอดภัยที่จะเรียก res.writeHead/res.end อีกครั้งสำหรับ URL หนึ่ง โดยไม่เช็กว่า response เสร็จไปแล้วหรือยัง สำหรับ /admin/audit (ซึ่งก็เป็น isAdmin ด้วย) res.end() ตอนนี้รันสองครั้งบน response เดียวกัน ซึ่งเป็น crash แบบ ERR_STREAM_WRITE_AFTER_END เดียวกับก่อนหน้านี้ในบทนี้เป๊ะ ถ้าไม่มี res.on('error', ...) listener โค้ดใดก็ตามที่เพิ่มเข้ามาหลังจุดที่ response อาจเสร็จไปแล้ว ต้องมี early return (หรือเช็ก res.writableEnded) ก่อนจะแตะ res อีกครั้ง — อย่าสมมติว่า “ที่นี่คือจุดเดียวที่ตอบ response”

จุดประสงค์ของ res.end() ใน Node.js HTTP server คืออะไร?
method ใดที่กำหนดทั้ง status code และ response headers ในครั้งเดียว?
จะเกิดอะไรขึ้นถ้าลืมเรียก res.end() ใน request handler?
handler เรียก res.end() สองครั้ง โดยไม่มี listener res.on('error', ...) เกิดอะไรขึ้น?
const server = http.createServer(function (req, res) {
  res.end('first');
  res.end('second');
});
ค่า default ของ server.keepAliveTimeout คือเท่าไร?
server ตัวที่สองพยายาม listen() บน port ที่ process อื่นเปิดอยู่แล้ว จะ emit อะไร และที่ไหน?
ทำไม res.end('hello') (เรียกครั้งเดียว) ถึงส่ง Content-Length: 5 ในขณะที่ res.write() สองครั้งตามด้วย res.end() ส่ง Transfer-Encoding: chunked แทน?
ทำไม handler ควรใช้ Buffer.byteLength(str) แทน str.length ตอนประกาศ Content-Length ของข้อความไทยเอง?