Lesson 2.3
The fixed-layout Request
The object the parser produces - nine fields in a frozen order, three header accessors, case-insensitive lookups, and a null-prototype map that shuts down prototype pollution.
- Explain why a fixed constructor field order helps an object that gets used on every request.
- Implement header(), headerAll(), and get headers() with case-insensitive lookup and list semantics.
- Explain why the header map has a null prototype and which attack it closes.
After a successful parse, the parser returns a Request. The series fixes its field list and declaration order once and for all. This lesson also adds three ways to read headers.
Three accessors, so the storage can change underneath
Today a header value is a plain string sitting in a map. A much later chapter rebuilds that storage completely - header values stop being strings and become offset ranges into the arrival buffer, materialized only when someone actually reads them, and that is what finally makes the parser live up to its title. That rebuild has to be invisible to every caller. The only way to guarantee this is to make callers go through methods, and never let them poke at the storage directly.
An accessor is a method used for reading stored data. Request provides header(name), headerAll(name), and the headers getter. A getter runs when code reads a property, so req.headers can compute and return a value without being called like req.headers(). Callers use these accessors instead of reading the internal map, so a later storage change can keep the same public behaviour.
Nine fields, one order
The constructor declares nine fields - every single one of them, in a fixed textual order. Here is what each one holds.
method "" the request method, e.g. "GET" (from the request line)
url "" the raw request-target, "/users?id=7" (from the request line)
path "" the target before the "?", "/users" (the router matches this)
rawQuery "" the target after the "?", "id=7" or "" (a later chapter parses it)
version "1.1" "1.1" or "1.0", the digits after HTTP/ (keep-alive reads this)
socket null the connection this request arrived on
app null the application that will dispatch it
_buf null the backing buffer the request's bytes live in
_headerMap null the header store; the parser fills it inFive fields store the request-line strings. socket and app are null today because the teaching server receives the socket directly and no application object exists yet. Still, they belong to the fixed property layout. _buf holds the buffer containing the request bytes. _headerMap stays null till the parser starts reading fields.
V8 records an internal property layout from the names and order of properties added to an object. V8 documentation and diagnostics call this layout a hidden class or map. Objects with the same layout can share the same optimized property-access code. A hot path is code that runs for every request, so small per-request costs keep on repeating. Later chapters insert new fields at reserved positions, and they don't reorder these nine.
Three ways to read a header
The accessors are small ones, but their contracts are exact.
header(name)returns astringorundefined. It lowercases the name you pass, soheader("Host"),header("host")andheader("HOST")all hit the same key. If the field repeated and the slot holds a list, it returns the first value.headerAll(name)returns astring[], and always a fresh array, never your internal storage. One element for a single value, a copy of the list for a repeated field, and an empty array when the header is absent. Returning a fresh array means a caller can never mutate what the parser stored.headersis a getter which returns a null-prototype object of all the headers. It is the documented slow path, meant for dumping everything while you're poking around. For a single lookup, preferheader().
All three must be null-safe. Before the parser assigns _headerMap, it is null, and calling any accessor should not throw.
One design question is worth answering out loud here. When a field repeats, why does header() return the first value and not the RFC's comma-joined form? Two reasons. First, it matches what the later offset-scan rewrite does (it returns on the first match), so this accessor's behaviour stays identical across that seam. A comma-join today would silently become a behaviour change later, or it would force that chapter to allocate exactly the joined strings it exists to avoid. Second, nothing is lost - headerAll(name) gives you every value, so a caller who wants the joined form can join the list themselves.
The null-prototype map
The header map is Object.create(null) and not a plain {}. A plain object inherits properties from Object.prototype through its prototype chain. The prototype chain is the sequence of objects JavaScript searches when an own property is absent. Names like __proto__, constructor and toString already have behaviour on that chain. A client controls its own header names, so those names must get stored as ordinary data. Changing an object's prototype through untrusted keys is called prototype pollution.
A null-prototype object has no inherited property chain at all. Every key, including __proto__, is an ordinary own property. The series uses null-prototype objects whenever client-controlled names become object keys.
Create src/request.js exporting class Request, constructed only by the parser (its constructor takes no arguments).
- The constructor declares the nine fields above, in that exact order, with those exact defaults.
header(name)- returnundefinedwhen_headerMapisnull. Otherwise look upname.toLowerCase(); returnundefinedif it's absent; return the value if it's a string, or its first element if it's an array.headerAll(name)- return[]when_headerMapisnullor the name is absent. Otherwise return[value]for a string, or a.slice()copy for an array - always a fresh array.get headers()- return the map when it exists, or a freshObject.create(null)when it's stillnull.
Don't add a tenth field, and don't reorder the nine.
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 can verify the empty property layout and the null-safe accessors even before the parser fills the object.
node -e '
import("./src/request.js").then(({ Request }) => {
const r = new Request();
console.log(Object.keys(r));
console.log("header on empty:", r.header("host"), "| headerAll:", JSON.stringify(r.headerAll("host")));
console.log("headers proto is null:", Object.getPrototypeOf(r.headers) === null);
})'[
'method', 'url',
'path', 'rawQuery',
'version', 'socket',
'app', '_buf',
'_headerMap'
]
header on empty: undefined | headerAll: []
headers proto is null: trueNine keys, in declaration order - v26's console lays the array out in aligned columns, so read it left to right, top to bottom. header returns undefined and headerAll returns [] before the parser has assigned any map, so nothing throws. And the headers getter's object has a null prototype, which means the prototype-pollution defense is in place from the very first instance.
- A caller mutates a header list and your parser's state changes along with it. Means
headerAllreturned your internal array by reference. Always return a fresh array,[v]orv.slice(). header("Host")returnsundefinedeven though the header is present. You didn't lowercase the queried name. Names are stored lowercase, so the accessor must lowercase its argument to match.- A request with a header named
__proto__corrupts later lookups. Means the map is a plain{}, so that key touched the prototype chain. UseObject.create(null). - The verify prints ten keys, or the columns come in the wrong order. You added a field or reordered them. The constructor declares exactly these nine properties in this order, nothing else.
Request is a fixed nine-field record with three header accessors. The accessors lowercase the lookup names and return fresh arrays without exposing the internal storage. Its null-prototype map stores hostile field names as ordinary data. The next lesson parses the request line into this object.