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.

30 minexamples/hello.js
  • 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 an HttpError - an expected rejection. A catch block receives any thrown value. err instanceof HttpError checks whether HttpError.prototype appears in the value's prototype chain. This is called narrowing, because the code inside that branch can treat the value as an HttpError. Call writeEarlyError(socket, err.statusCode). For any other error, throw it again, so a programming defect doesn't quietly become a 400 response.
  • next() returns null (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 with writeEarlyError(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 a Request - success. Build the body as req.method + " " + req.path, and hand-write the response the same way as Chapter 1 - status line, content-length computed with Buffer.byteLength (octets, not characters), the header block terminator, then the body. There is still no Response object; 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.

  1. The inclusive slice. If you slice [start, i] instead of [start, i), you get "GET " with a trailing space. It's invisible, and every method === "GET" comparison comes out false. The half-open token bounds from lesson 4 prevent this.
  2. Case-sensitive header storage. If you store Host verbatim and then look up host, the lookup misses. The lowercase-on-store rule from lesson 5 prevents this.
  3. The query stays attached to the path. Without the ? split, the path is /users?id=7, so a route registered for /users will not match. Lesson 4 separates the two values.
  4. 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.
  5. Whitespace before the colon. If you trim Host : evil and accept it, a lenient-strict parser pair can be smuggled through. The reject from lesson 5 prevents this.
Build it

First, the real deliverable. Rewrite examples/hello.js.

  • Import Server and writeEarlyError from ../src/server.js, Parser from ../src/parser.js, and HttpError from ../src/errors.js.
  • Construct new Server((socket, chunk) => { ... }). Inside it, make a fresh Parser, push(chunk), and call next() in a try.
    • In catch (err) - if err instanceof HttpError, then return writeEarlyError(socket, err.statusCode); otherwise throw err.
    • If the result is null, return writeEarlyError(socket, 400).
    • Otherwise build body = req.method + " " + req.path and socket.write a hand-built 200 OK response whose content-length is Buffer.byteLength(body), followed by the body.
  • 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.

Hints

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.

    Verify it

    Start the server and hit it. The method echoes exactly, and the path echoes without its query.

    bash
    PORT=3202 node examples/hello.js
    bash
    curl -s "http://127.0.0.1:3202/users?id=7"
    curl -s "http://127.0.0.1:3202/a/b/c" -X DELETE
    text
    GET /users
    DELETE /a/b/c

    Now count the raw bytes, which is the real proof of framing. od -c prints each byte.

    bash
    printf 'GET /users?id=7 HTTP/1.1\r\nhost: x\r\n\r\n' | nc -w1 127.0.0.1 3202 | od -c
    text
    0000000    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
    0000061

    The 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.

    bash
    printf 'GET / HTTP/1.1\r\nHost : evil\r\n\r\n' | nc -w1 127.0.0.1 3202 | head -1
    text
    HTTP/1.1 400 Bad Request

    Finally, 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.

    bash
    npm run check -- 02-request-parser
    npm run check -- --through 2
    text
    check 02-request-parser ... PASS
    check 01-raw-socket-echo ... PASS
    check 02-request-parser ... PASS

    The 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!.

    Common mistakes
    • curl echoes GET /users?id=7 with 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 catch isn't narrowing to HttpError, so it either swallows real bugs as 400s or lets a non-HttpError escape. 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 Connection accumulates across reads and turns that null from "reject" into "wait".
    • content-length is off by one on a multibyte path. Means you used body.length (UTF-16 code units) instead of Buffer.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.