Lesson 1.3
When it breaks - three ways to get one response wrong
Reproduce the three real framing failures (the endless hang, the bare-LF problem, the truncated multibyte tail) by breaking your own response on purpose, and then read each failure on the wire.
- Reproduce the missing-frame hang and read curl's own diagnosis of it.
- Show that curl and undici disagree on bare LF, and know which one you have to satisfy.
- Make a multibyte body truncate and trace it back to str.length vs Buffer.byteLength.
Your response worked because its framing was exact. In this lesson we'll remove one framing rule at a time and see what happens. For each test, copy hello.js to a scratch file, change that copy, record the result, and delete the copy. A scratch file is just temporary work which doesn't become part of the project.
Break 1 - delete Content-Length and curl hangs forever
In your scratch copy, remove the single field line that frames the body. Delete the content-length line from the head and nothing else. Keep the connection open (don't close the socket).
Start it, and limit curl to three seconds. curl is a command-line HTTP client. --max-time 3 stops the command after three seconds. $? is a shell variable which holds the previous command's numeric exit status. Exit status of zero means success, and a nonzero status means some failure happened.
curl -s --max-time 3 http://127.0.0.1:3124/ ; echo "exit: $?"Hello, World!
exit: 28The body arrived, but curl still kept waiting. Then it exited with 28, which is the curl code for an operation timeout. Run it in verbose mode to print the protocol details.
curl -sv --max-time 3 http://127.0.0.1:3124/ 2>&1 | grep -iE 'no chunk|timed out|Closing'* no chunk, no close, no size. Assume close to signal end
* Operation timed out after 3006 milliseconds with 13 bytes received
* Closing connection2>&1 sends standard error to the same pipe as standard output. Standard output is the normal command result, and standard error carries the diagnostics. grep -iE filters both streams with a case-insensitive extended regular expression. The quoted alternatives keep only the lines containing no chunk, timed out, or Closing.
"No chunk, no close, no size" refers to chunked coding, connection close, and content-length. You supplied none of them and kept the socket open, so curl sat waiting for a connection close. Put the field line back and the response finishes after 13 body bytes.
Break 2 - bare LF, and clients disagree
In the scratch copy, replace every \r\n in the head with a bare \n. Leave the body as it is.
curl accepts it without any complaint. The spec lets a recipient treat a lone LF as a line terminator, and curl uses that freedom.
curl -s -i http://127.0.0.1:3125/HTTP/1.1 200 OK
content-length: 13
Hello, World!Now send the same request with Node's fetch function. fetch returns a Promise for an HTTP response. Node implements it with an HTTP client library called Undici.
node -e 'fetch("http://127.0.0.1:3125/").then(r=>r.text()).then(console.log).catch(e=>console.log("REJECTED:", e.cause?.message ?? e.message))'REJECTED: Response does not match the HTTP/1.1 protocol (Missing expected CR after response line)Undici's parser requires the CR and reports Missing expected CR after response line. The RFC keyword MAY permits a recipient to accept a bare LF, but it doesn't require it. So both client behaviours conform to the rule. A conforming sender always emits CRLF, no exceptions.
Break 3 - str.length vs Buffer.byteLength
In the scratch copy, make two changes. Set the body to something with a multibyte character, like Hello, Wörld!, and take the length from .length instead of Buffer.byteLength. A multibyte character uses more than one byte in the selected byte encoding.
// This uses UTF-16 code units instead of encoded bytes.
"content-length: " + body.lengthThe two counts disagree by exactly one.
node -e 'const s="Hello, Wörld!"; console.log("str.length:", s.length, "byteLength:", Buffer.byteLength(s))'str.length: 13 byteLength: 14You advertise 13, but the body is actually 14 bytes. The client trusts the number, reads 13, and cuts off the last byte.
curl -s http://127.0.0.1:3126/Hello, WörldThe final ! is gone. The client consumed 13 out of the 14 bytes and stopped one short. On a reused connection, that leftover byte would become the first byte of the next response - and that is corruption, not just a visible truncation.
content-length counts octets. str.length counts UTF-16 code units, which is neither octets nor characters. For any body with a non-ASCII byte, the two diverge, and the client either truncates (too small) or hangs (too large). The fix is the same one your real hello.js already uses - Buffer.byteLength(str).
Reproduce all three tests in order. Copy examples/hello.js to a scratch file, introduce the one defect described, and run the given command. Delete the scratch files after recording the output. This exercise doesn't add any permanent source file.
Basically, you should see all of these with your own eyes - the exit-28 hang with curl's "no chunk, no close, no size" line, the undici REJECTED: against a server which curl happily accepts, and the missing ! sitting next to a str.length / Buffer.byteLength mismatch.
Reveal these one at a time, and only when you are genuinely stuck. Each one tells you a little more than the last - none of them is the answer.
You've reproduced this lesson correctly when you have seen all three of these in your own terminal.
- a
curlthat printed the body but still exited with28, and then printedno chunk, no close, no size; - a
node -e 'fetch(...)'that printedREJECTED:with a CR complaint, against a server which curl accepted; - a
curlwhose body lost its final character, next to anode -ethat printedstr.length: 13 byteLength: 14.
After that, delete the throwaway break files. Your real src/server.js and examples/hello.js were never touched, and they still serve the correct 52 bytes.
- Break 1 is not hanging for you. Maybe some proxy is closing idle connections, or
content-lengthis still present in the file. A proxy is an intermediary that forwards a client's request to another server. Connectcurldirectly to127.0.0.1and check the scratch file once. - Break 2's fetch call succeeds. Even a single surviving
\r\non the status line is enough for undici to accept the response. Change every separator in the head. - Break 3 shows no truncation. Means your body has no multibyte character. The bug appears only when
str.lengthandBuffer.byteLengthactually differ, and theöis what makes them differ by one.
You recorded a missing-length timeout, a bare-LF rejection, and a truncated multibyte body. The next lesson measures the delay caused by a TCP socket setting.