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
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
/* 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:
xhttpfieldis a {Name, Value} view pair — the views must live until ResponseWrite returns, hence the length string sits in a stack array before xrtStrViewxrtHttp1ResponseWriteonly 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: closecloses 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:
/* 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
- 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).
- 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).
xrtTlsStreamSend(pReq->tls, p, n,
&iWritten); /* iWritten ignored */== XTLS_OK && iWritten == nExercises
Implement ReplyText / ReplyHTML / ReplyJSON / ReplyBinary (Content-Disposition attachment header) on this chapter's pattern.
Check your routes' HEAD behavior with curl -I; if 405, fix it with chapter 17's method masks.
Implement /counter with chunked: count 1 to 100, one chunk per second — laying groundwork for chapter 22's SSE-style push.
Summary
| Point | Conclusion |
|---|---|
| The trio | ConnSend (transport) / ReplyRaw (head+body) / semantic wrappers |
| Generation ≠ transport | ResponseWrite only produces head bytes into your buffer |
| Content-Length | compute exactly — keep-alive's lifeline; check TLS short writes |
| HEAD | headers like GET, body skipped, handled in the transport layer |
| chunked | ChunkWrite frames each piece; the terminal chunk closes |
- xrt reference · http1 — full signatures of ResponseWrite / ChunkWrite / HttpStatusText
- xrt reference · http_trailer — reading and writing chunked trailers (≤8 fields)