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.

25 minsrc/request.js
  • 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.

text
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 in

Five 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 a string or undefined. It lowercases the name you pass, so header("Host"), header("host") and header("HOST") all hit the same key. If the field repeated and the slot holds a list, it returns the first value.
  • headerAll(name) returns a string[], 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.
  • headers is 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, prefer header().

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.

Build it

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) - return undefined when _headerMap is null. Otherwise look up name.toLowerCase(); return undefined if it's absent; return the value if it's a string, or its first element if it's an array.
  • headerAll(name) - return [] when _headerMap is null or 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 fresh Object.create(null) when it's still null.

Don't add a tenth field, and don't reorder the nine.

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

    You can verify the empty property layout and the null-safe accessors even before the parser fills the object.

    bash
    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);
      })'
    text
    [
      'method',     'url',
      'path',       'rawQuery',
      'version',    'socket',
      'app',        '_buf',
      '_headerMap'
    ]
    header on empty: undefined | headerAll: []
    headers proto is null: true

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

    Common mistakes
    • A caller mutates a header list and your parser's state changes along with it. Means headerAll returned your internal array by reference. Always return a fresh array, [v] or v.slice().
    • header("Host") returns undefined even 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. Use Object.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.