Chapter 15 / 51
Volume 3 · Protocol Development

Building responses: headers, chunked and keep-alive

The response is a byte stream you write yourself. This chapter takes apart and reassembles the demo's response helpers — the same set routebench's ten-thousand-route app ran on — the prototype for your modules/http.h.

Overview

Positioning: afterwards you can write text / JSON / HTML / chunked responses from scratch, and you understand why HEAD and keep-alive must be your responsibility (not the framework's).

15.1 Intro

"No Response object" means three jobs are yours: generating the head bytes (xrtHttp1ResponseWrite), writing to the transport (the ConnSend helper), and keeping protocol semantics (Content-Length exact, HEAD sends no body). It looks like chores, but buys zero hidden behavior — and once written, this code becomes your modules/http.h, reused by the whole app.

15.2 Complete responses: the ReplyRaw pattern

The core three of modules/http.hverified on a real machinesource: demo-single/modules/http.h (excerpt)
/* 1. transport helper: send via whichever transport the request uses */
static bool ConnSend(XS_HttpReq* pReq, const void* pData, size_t iSize)
{
    size_t iWritten = 0;
    if ( pReq->tls != NULL ) {
        /* TLS permits successful short writes: done only when fully accepted */
        return xrtTlsStreamSend(pReq->tls, pData, iSize,
            &iWritten) == XTLS_OK && iWritten == iSize;
    }
    return pReq->tcp != NULL &&
        xrtNetStreamSend(pReq->tcp, pData, iSize) == XNET_RESULT_OK;
}

/* 2. complete response: head + body written out together */
static bool ReplyRaw(XS_HttpReq* pReq, uint16 iStatus,
    const char* sContentType, const void* pBody, size_t iBodySize)
{
    char arrHead[1024], arrLength[32];
    xhttpfield arrField[12];
    size_t iFieldCount = 0, iHeadSize = 0;

    snprintf(arrLength, sizeof(arrLength), "%llu",
        (unsigned long long)iBodySize);
    arrField[iFieldCount].Name  = XRT_STR_LITERAL("Content-Length");
    arrField[iFieldCount].Value = xrtStrView(arrLength);
    iFieldCount++;
    /* ... optional Content-Type and extra fields ... */

    if ( !xrtHttp1ResponseWrite(XHTTP_VERSION_1_1, iStatus,
        xrtHttpStatusText(iStatus), arrField, iFieldCount,
        arrHead, sizeof(arrHead), &iHeadSize) ) return false;
    if ( !ConnSend(pReq, arrHead, iHeadSize) ) return false;
    return pReq->head->MethodCode == XHTTP_METHOD_HEAD ||
        ConnSend(pReq, pBody, iBodySize);
}

/* 3. semantic wrappers */
static bool ReplyText(XS_HttpReq* pReq, uint16 iStatus, const char* sText)
{
    return ReplyRaw(pReq, iStatus, "text/plain; charset=utf-8",
        sText, strlen(sText));
}

What just happened — four key points:

  • xhttpfield is a {Name, Value} view pair — the views must live until ResponseWrite returns, hence the length string sits in a stack array before xrtStrView
  • xrtHttp1ResponseWrite only generates head bytes (into your buffer), never sends — generation and transport are decoupled
  • You compute Content-Length and stringify it — it is the anchor of keep-alive correctness
  • The HEAD branch: keep the head (with Content-Length) but skip the body

15.3 HEAD & keep-alive

  • keep-alive: connections are reused by default (HTTP/1.1 semantics); a client's Connection: close closes after this response. After XS_OK the driver drains the remaining body and reads the next request — provided your Content-Length is exact; too much or too little misaligns the next request's parsing
  • HEAD: semantically "GET's metadata" — headers should match GET's (including Content-Length) with no body written. One line in ReplyRaw does it
  • Restricted pipelining: the next request isn't parsed until the static file finishes sending — application responses follow the same "finish writing, then return the connection" discipline

15.4 chunked streaming responses

Content of unknown length or generated in pieces uses chunked encoding — each piece is framed by xrtHttp1ChunkWrite, closed by the terminal chunk:

chunked response skeleton
/* head: Transfer-Encoding: chunked (no Content-Length) */
xhttpfield arrField[1] = {
    { XRT_STR_LITERAL("Transfer-Encoding"),
      XRT_STR_LITERAL("chunked") } };
xrtHttp1ResponseWrite(XHTTP_VERSION_1_1, 200,
    xrtHttpStatusText(200), arrField, 1, arrHead, sizeof(arrHead), &iHeadSize);
ConnSend(pReq, arrHead, iHeadSize);

/* chunk by chunk */
while ( HasMore(&tGen) ) {
    char arrChunk[4096];
    size_t iChunkSize = NextChunk(&tGen, arrChunk, sizeof(arrChunk));
    char arrFrame[4096 + 32];
    size_t iFrameSize = 0;
    if ( xrtHttp1ChunkWrite(arrChunk, iChunkSize,
        arrFrame, sizeof(arrFrame), &iFrameSize) )
        ConnSend(pReq, arrFrame, iFrameSize);
}
/* terminal chunk (0-length) — also generated via ChunkWrite */

In a chunked response HEAD likewise writes no body (the head need not even carry Transfer-Encoding — you may degrade to an empty GET-style head; pick a policy and stay consistent).

15.5 Contract

  • Length: Content-Length must exactly equal the body's byte count — the lifeline of keep-alive.
  • HEAD: headers like GET, body skipped; handle it in the transport helper, never per handler.
  • Reuse: response helpers go into modules/http.h with full ownership comments (ReplyJSON only borrows pObject).

Common mistakes

Content-Length not matching reality
Symptom
Under keep-alive the next requests misparse; clients hang or report malformed responses.
Cause
Over/under-counting the body (strlen on binary with \0, editing the content after computing the length).
Ignoring TLS short writes
Symptom
Sporadic truncated responses over HTTPS.
Cause
Judging success by xrtTlsStreamSend's return alone — TLS permits successful short writes; you must check written == size (ConnSend's way).
✗ Wrong
xrtTlsStreamSend(pReq->tls, p, n,
    &iWritten);          /* iWritten ignored */
✓ Right
== XTLS_OK && iWritten == n

Exercises

BasicFour kinds of Reply

Implement ReplyText / ReplyHTML / ReplyJSON / ReplyBinary (Content-Disposition attachment header) on this chapter's pattern.

Acceptance: curl parses all four correctly; verify the binary with md5.
AdvancedVerifying the HEAD path

Check your routes' HEAD behavior with curl -I; if 405, fix it with chapter 17's method masks.

Acceptance: HEAD returns GET-identical headers (with Content-Length) and no body.
ChallengeAn endless stream (warm-up)

Implement /counter with chunked: count 1 to 100, one chunk per second — laying groundwork for chapter 22's SSE-style push.

Acceptance: curl receives 100 chunks progressively; Ctrl+C mid-stream doesn't crash the server (hint: cleanup on the send-failure path).

Summary

PointConclusion
The trioConnSend (transport) / ReplyRaw (head+body) / semantic wrappers
Generation ≠ transportResponseWrite only produces head bytes into your buffer
Content-Lengthcompute exactly — keep-alive's lifeline; check TLS short writes
HEADheaders like GET, body skipped, handled in the transport layer
chunkedChunkWrite frames each piece; the terminal chunk closes
xrt further reading