Lesson 2.6
Wire it live, then break it five ways
Rewrite the teaching server to parse the request and echo the method and path, reject bad framing through writeEarlyError, and then reproduce all five stage-02 failures on the wire and pass the check.
- Wire the parser, Request, and writeEarlyError into a server that echoes METHOD /path.
- Reproduce the five stage-02 failures with the relevant isolated or socket check.
- Pass the stage-02 check and the through-2 regression.
The parser works in isolation. In this lesson we'll rewrite the teaching server so it parses a request and echoes its method and path back. Malformed input goes through writeEarlyError. The final checks repeat the parser failures from the earlier lessons, and they exercise the complete server over a real socket.
From bytes to an echo
Chapter 1's hello.js ignored the request and wrote a constant thirteen bytes. Now it'll parse the request off the chunk, echo METHOD /path in the body, and reject malformed framing through writeEarlyError. The wiring is small, but three parts of it are deliberate seams and it's worth naming them clearly - because two of them are lies which this chapter tells on purpose, and later chapters fix them.
The server still uses the onData(socket, chunk) interface from Chapter 1. Inside the callback, you make a fresh Parser, push the chunk, and call next() inside a try block. The callback has three possible outcomes.
next()throws anHttpError- an expected rejection. Acatchblock receives any thrown value.err instanceof HttpErrorchecks whetherHttpError.prototypeappears in the value's prototype chain. This is called narrowing, because the code inside that branch can treat the value as anHttpError. CallwriteEarlyError(socket, err.statusCode). For any other error, throw it again, so a programming defect doesn't quietly become a 400 response.next()returnsnull(WANT_MORE) - the request is incomplete. Under this chapter's whole-request assumption, there is nowhere to hold the first half while the second half arrives, so you reject it withwriteEarlyError(socket, 400). Let me say this plainly - this is wrong for a client that genuinely split its request across packets, and this is exactly the lie which the next chapter fixes with a per-connection object that accumulates across reads. The stage-02 check sends whole requests, so the lie holds for exactly one chapter.next()returns aRequest- success. Build the body asreq.method + " " + req.path, and hand-write the response the same way as Chapter 1 - status line,content-lengthcomputed withBuffer.byteLength(octets, not characters), the header block terminator, then the body. There is still noResponseobject; that comes in a later chapter.
So this chapter creates one Parser per 'data' event and treats null as a 400 response. Chapter 3 replaces both of these behaviours with per-connection parsing. The HttpError check stays as it is.
Break it five ways
The chapter has five checks. Use a scratch copy whenever a check needs deliberately broken code. Some parser properties are visible only in an isolated parser call, while response framing and rejection are visible over the socket.
- The inclusive slice. If you slice
[start, i]instead of[start, i), you get"GET "with a trailing space. It's invisible, and everymethod === "GET"comparison comes out false. The half-open token bounds from lesson 4 prevent this. - Case-sensitive header storage. If you store
Hostverbatim and then look uphost, the lookup misses. The lowercase-on-store rule from lesson 5 prevents this. - The query stays attached to the path. Without the
?split, the path is/users?id=7, so a route registered for/userswill not match. Lesson 4 separates the two values. toString().split(). If you materialize the whole request eagerly, you manufacture garbage which you never needed in the first place. We measured this in lesson 1.- Whitespace before the colon. If you trim
Host : eviland accept it, a lenient-strict parser pair can be smuggled through. The reject from lesson 5 prevents this.
First, the real deliverable. Rewrite examples/hello.js.
- Import
ServerandwriteEarlyErrorfrom../src/server.js,Parserfrom../src/parser.js, andHttpErrorfrom../src/errors.js. - Construct
new Server((socket, chunk) => { ... }). Inside it, make a freshParser,push(chunk), and callnext()in atry.- In
catch (err)- iferr instanceof HttpError, thenreturn writeEarlyError(socket, err.statusCode); otherwisethrow err. - If the result is
null,return writeEarlyError(socket, 400). - Otherwise build
body = req.method + " " + req.pathandsocket.writea hand-built200 OKresponse whosecontent-lengthisBuffer.byteLength(body), followed by the body.
- In
- Listen on
Number(process.env.PORT ?? 3000)/process.env.HOST ?? "127.0.0.1"and log the URL once it's listening.
Then repeat the earlier observations. Use the lesson 4 request-line commands for the method range and the query split. Use the lesson 5 parser command for mixed-case and repeated fields. Use the lesson 1 allocation measurement for whole-buffer splitting. And use the raw Host : evil command below for the whitespace reject. Delete any scratch copy once its result is recorded.
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.
Start the server and hit it. The method echoes exactly, and the path echoes without its query.
PORT=3202 node examples/hello.jscurl -s "http://127.0.0.1:3202/users?id=7"
curl -s "http://127.0.0.1:3202/a/b/c" -X DELETEGET /users
DELETE /a/b/cNow count the raw bytes, which is the real proof of framing. od -c prints each byte.
printf 'GET /users?id=7 HTTP/1.1\r\nhost: x\r\n\r\n' | nc -w1 127.0.0.1 3202 | od -c0000000 H T T P / 1 . 1 2 0 0 O K \r
0000020 \n c o n t e n t - l e n g t h :
0000040 1 0 \r \n \r \n G E T / u s e r
0000060 s
0000061The body GET /users contains ten bytes and the response sends content-length: 10. The next command sends a field name which has a space before its colon.
printf 'GET / HTTP/1.1\r\nHost : evil\r\n\r\n' | nc -w1 127.0.0.1 3202 | head -1HTTP/1.1 400 Bad RequestFinally, run the provided stage check against your build, and the regression through stage 2. A stage check is course tooling which starts the example server in a child process and probes it over a real TCP socket. The --through 2 argument selects every check whose numeric prefix is at most 2.
npm run check -- 02-request-parser
npm run check -- --through 2check 02-request-parser ... PASS
check 01-raw-socket-echo ... PASS
check 02-request-parser ... PASSThe stage-02 check confirms the exact echoed method, the query split, a valid body length, and the 400 response for whitespace before the colon. The isolated lesson 5 verification checks the case-insensitive lookup, because that internal accessor is not observable in this example's response. Stage 01 checks response framing without demanding one fixed body, so it stays valid even after the echo replaces Hello, World!.
curlechoesGET /users?id=7with the query still attached. The?split is missing. The path must be the target up to the first?.- A route comparison against
"GET"never matches. The method is carrying a trailing space from an inclusive slice. Tokens are[start, i). - Every malformed request returns a 200 with some weird body, or the process crashes on bad input. Means your
catchisn't narrowing toHttpError, so it either swallows real bugs as 400s or lets a non-HttpErrorescape. Narrow the catch, rethrow the rest. - A request split across two packets gets a 400 from your server. That is the whole-request lie, and it's working as designed for exactly this chapter. The next chapter's
Connectionaccumulates across reads and turns thatnullfrom "reject" into "wait". content-lengthis off by one on a multibyte path. Means you usedbody.length(UTF-16 code units) instead ofBuffer.byteLength(body)(octets).
So now you own a byte-level HTTP/1.1 parser, a single HttpError type, a below-framework reject path, and a server which reads what the client actually asked for and refuses ambiguous framing outright. You've seen all five ways it breaks, and the stage-02 check is green. One assumption is still standing between you and real traffic - that a single data event carries one whole request. It doesn't hold. The next chapter replaces the fresh-parser-per-event seam with a per-connection object which accumulates bytes across reads and resumes next() from a saved cursor, so a WANT_MORE null means wait instead of reject.