Lesson 2.4
Walking the request line
Byte codes instead of characters, half-open [start, i) token bounds, the invisible off-by-one, and the request-line scan that fills method, path, query and version - all inside the parser skeleton it lives in.
- Scan the request line into method, request-target and version using integer byte compares.
- Explain why a token is [start, i) and how including the delimiter creates an invisible bug.
- Split the request-target on the first ? into path and rawQuery, and validate the version by bytes.
- Return null for WANT_MORE and throw HttpError(400) for malformed framing.
Now comes the parser itself. You'll build it as a state machine that walks the arrival buffer one byte at a time, and this lesson does the first half - the skeleton, plus the scan of the request line into method, path, query and version. The scan has one classic bug, a trailing space which is invisible in the terminal and silently breaks every route match. You'll prevent it by construction, using the token rule below.
Byte codes, not characters
You're comparing raw octets here, so you work in byte codes, not character literals. buf[i] returns a number from 0 to 255. Comparing it with an integer is about as cheap as computing gets, and it keeps the scan loop free from string comparisons, which would allocate and run slower. Name the codes you branch on once, and use those names everywhere.
SP = 0x20 space ' '
HTAB = 0x09 horizontal tab '\t'
CR = 0x0d carriage return '\r'
LF = 0x0a line feed '\n'
COLON = 0x3a ':'
QMARK = 0x3f '?'buf[i] === SP is an integer comparison. Walking bytes by code uses these numeric values without creating any strings.
A token is [start, i)
A delimiter is a byte that marks the end of a token. The space after GET is the method delimiter. A half-open range includes its start index and excludes its end index.
a token is the half-open range [start, i)
-- from start up to but NOT INCLUDING the delimiter byte at iThe scan pattern never changes. You remember where a token started, walk forward till you hit its delimiter, and the token is everything you walked over - not the delimiter itself. Both subarray(start, end) and toString(enc, start, end) are end-exclusive i.e they take bytes [start, end). So when your loop stops with i sitting on the space after the method, the method is [0, i).
An incorrect end index can pull in the delimiter also. Say the method scan stops on the space at index 3.
Figure 2.3 - The half-open range ends before the delimiter, keeping the invisible trailing space out of the method token.
"GET " with a trailing space renders exactly same as "GET" in every terminal, it echoes back looking perfect, and it makes every method === "GET" comparison come out silently false. Your router will never match a route, and you won't be able to see why. The same off-by-one on the version scan drags a trailing \r onto the version. So the half-open range is a correctness requirement here. If you get it wrong, the parser looks fine while every comparison keeps failing.
The scan pattern, and what counts as malformed
Every token uses the identical loop. Here is the method in pseudocode.
i = 0
while buf[i] != SP: # walk to the delimiter
if buf[i] is CR or LF: reject # a line break inside a token is malformed
i++
method = buf[0 .. i) # the token: exclusive of the delimiter at i
i++ # step over the SPThe target and version use the same loop structure. Walk to the delimiter, take [start, i) as the token, then step over the delimiter. The target ends at the next SP. The version ends at CR, after which the parser confirms the LF.
- Running out of bytes means WANT_MORE. If
ireaches the end of the buffer mid-token (i >= len), the request is incomplete, not malformed. Youreturn null. That is the honest "come back with more bytes". - A
CRorLFinside a token means malformed. A line break sitting where a token byte should be means the request line is broken - there is no legal newline inside a method, target or version. That throwsHttpError(400, "ERR_MALFORMED_REQUEST_LINE"). Same goes for an empty token (two spaces in a row, or a leading space).
Splitting the target, and checking the version
The request-target /users?id=7 contains a path (/users) and a query string (id=7). The router later matches the path, and another chapter parses the query string. url stores the complete target. path stores the bytes before the first ?. rawQuery stores the bytes after that delimiter, excluding the first ?, or "" when the delimiter is absent. Any later ? bytes stay inside rawQuery as it is.
Validate the version without building any string. It must be exactly eight bytes, HTTP/1. followed by 1 or 0. Compare H, T, T, P, /, 1, ., and the final version digit at their known offsets. Reject HTTP/2.0, a truncated version, and anything else. Then materialize the three bytes 1.1 or 1.0 which come after HTTP/.
The scan materializes method, URL, version, path, and a nonempty query. It never creates a whole-request string or a line array.
The parser skeleton
The scan lives inside a Parser class. This lesson builds its skeleton plus the request-line half of next().
The push(chunk) method in this chapter stores just one chunk. It assumes one 'data' event carries one complete request. Chapter 3 replaces it with accumulated bytes and a saved cursor. A cursor is the numeric index of the next byte the parser will inspect.
next() returns a Request when a full header block has arrived, null (WANT_MORE) when it needs more bytes, and it throws HttpError(400) on malformed framing. This lesson builds next() till the end of the request line and up to the header block. After the request line, you recognize the empty header block (an immediate CRLF terminator) and return the Request. A request that actually carries field lines comes in the next lesson, so you'll verify this stage with a header-less request.
Create src/parser.js.
- Export the eight state constants with values 0 through 7, in this order -
REQUEST_LINE,HEADERS,READ_BODY,CHUNK_SIZE,CHUNK_DATA,CHUNK_CRLF,TRAILERS,DONE. All eight exist from now itself, so later chapters keep the same state names and values. - Define the six byte-code constants above as module-private values.
- Import
HttpErrorfrom./errors.jsandRequestfrom./request.js. - A
class Parserwith the members below.constructor(options = {})- three fields in fixed order._options(storeoptions; it stays unused for several chapters),_buf = null,_state = REQUEST_LINE.reset()- set_buf = nulland_state = REQUEST_LINE.push(chunk)- storethis._buf = chunk.next()- walk a local cursorifrom 0.- If
_bufisnull, returnnull. - Scan the method to the first
SP(rejectingCR/LFinside, and an empty method, asERR_MALFORMED_REQUEST_LINE;i >= lenreturnsnull). Step over theSP. - Scan the target to the next
SP(same rejects). Step over theSP. - Scan the version to
CR; then confirm the following byte isLF(i >= lenat either point returnsnull; aCRnot followed byLFis malformed). Step over the CRLF. - Validate the version token - exactly 8 bytes,
HTTP/1.1orHTTP/1.0, compared byte by byte; anything else isERR_MALFORMED_REQUEST_LINE. - Construct a
Request, set_bufon it, and fillmethod,url, andversion(the1.xpart) withbuf.toString("latin1", ...). - Scan the target's bytes for the first
QMARK; setpathandrawQueryaccordingly (the whole target and""when there is no?). - Set
_state = HEADERS. Then handle the header block terminator only - if the next byte isCRand the one after isLF, step over both, set_state = DONE, and return theRequest. Field-line parsing comes in the next lesson.
- If
- Every malformed case throws
new HttpError(400, "ERR_MALFORMED_REQUEST_LINE"), and every "not enough bytes yet" returnsnull.
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.
Feed the parser a header-less request and check what the request-line scan produced.
node -e '
import("./src/parser.js").then(({ Parser }) => {
const p = new Parser();
p.push(Buffer.from("GET /users?id=7 HTTP/1.1\r\n\r\n", "latin1"));
const req = p.next();
console.log("method :", JSON.stringify(req.method));
console.log("path :", JSON.stringify(req.path));
console.log("rawQuery:", JSON.stringify(req.rawQuery));
console.log("url :", JSON.stringify(req.url));
console.log("version :", JSON.stringify(req.version));
})'method : "GET"
path : "/users"
rawQuery: "id=7"
url : "/users?id=7"
version : "1.1"method is exactly "GET" with no trailing space - that invisible bug, prevented by construction. The path is /users, with the query stripped off into rawQuery. Now confirm the WANT_MORE case - an incomplete chunk returns null, it doesn't throw.
node -e '
import("./src/parser.js").then(({ Parser }) => {
const p = new Parser();
p.push(Buffer.from("GET /us", "latin1"));
console.log("mid-line:", p.next());
})'mid-line: nullAnd a bad version throws a 400.
node -e '
import("./src/parser.js").then(({ Parser }) => {
const p = new Parser();
p.push(Buffer.from("GET / HTTP/2.0\r\n\r\n", "latin1"));
try { p.next(); console.log("NO THROW -- bug"); }
catch (e) { console.log(e.code, e.statusCode); }
})'ERR_MALFORMED_REQUEST_LINE 400method === "GET"comes outfalseeven though it echoes asGET- means you sliced[start, i]inclusive and dragged the delimiter space into the token. The token is[start, i), and the delimiter atiis never part of it.- The path comes out as
/users?id=7- you split the target on the space but never on the?. Scan for the first?inside the target and cut there; everything after it israwQuery. - A truncated
GET / HTTP/1orHTTP/2.0gets accepted - means you didn't validate the version by bytes. It must be exactly eight octets,HTTP/1.1orHTTP/1.0. - An incomplete request throws a 400 instead of returning
null- you treated "ran out of bytes mid-token" as malformed. Out of bytes is WANT_MORE (return null); only aCR/LFinside a token or an empty token is a reject.
Your parser now has a skeleton and it reads a request line (method, url, path, rawQuery and version) by walking each byte only once, materializing just the four tokens it keeps, splitting the query off the path, and validating the version octet by octet. It returns null for want-more and throws a 400 for a broken line. It stops at the header block. The next lesson fills in the field-line loop, including the whitespace-before-colon reject.