Render

Server-side HTML / JSON / XML response helpers. Each helper picks the right Content-Type, sets a sensible Cache-Control + security header default, and ends the response in one call — replacing the five-line writeHead / stringify / Content-Length / end ritual that every route handler otherwise reimplements.

Module-level helpers (json / text / htmlString / redirect) work without a template engine. create({ engine }) wraps a b.template.create instance and returns the same helpers plus html(res, viewName, data?) for engine-rendered pages. Operators who never render server-side HTML import only the module-level helpers and skip the engine wiring entirely.

All helpers fall through silently when res.writableEnded === true, so a late Promise rejection after res.end can't corrupt the wire with a half-written second body. The default Cache-Control is private, no-cache, must-revalidate — overridable via opts.headers["Cache-Control"] for CDN-cacheable responses. When the response already carries a Cache-Control with no-store, such as the one b.middleware.noCache sets, the helpers send that value instead, both in place of the default and in place of a Cache-Control the caller passes in opts.headers. A route that must stay cacheable is kept out of the middleware's scope with its when predicate.

b.render.json(res, body, opts) #

stable0.1.0
{
  status:   200,                  // numeric HTTP status (200/201/202/4xx/5xx)
  headers:  {},                   // merged over defaults; later wins
  replacer: function|string[],    // JSON.stringify replacer (BigInt/Date/redaction)
}

JSON-stringifies body and writes it to res with Content-Type application/json; charset=utf-8, an explicit Content-Length, and the dynamic-response Cache-Control. Status defaults to 200; any custom headers in opts.headers merge over the defaults so operators can pin a different Cache-Control or add CORS headers without losing Content-Type. A no-store already on the response survives that merge. The Content-Type it sends is its own: this helper encodes the body, so the type describes the bytes it produced rather than a preference, and a text/html left on the response by an earlier res.setHeader does not carry over — that is how a JSON error body comes to be parsed as markup. Say it in opts.headers to send something else. Returns undefined — the response is fully written by the time the call returns.

opts.replacer is forwarded to JSON.stringify (ECMA-262 §25.5.2, the second argument) so handlers can serialize values that have no native JSON form — BigInt (which otherwise throws), Date in a custom shape, Map / Set, or a redaction filter over secret- shaped keys — without pre-walking the body. Accepts the same function or property-name array JSON.stringify does; a non- function / non-array value is a config typo and throws.

b.render.json(res, { ok: true, id: 42 }, { status: 201 });
// → response: 201, application/json, body `{"ok":true,"id":42}`

b.render.json(res, { total: 9007199254740993n }, {
  replacer: function (k, v) { return typeof v === "bigint" ? v.toString() : v; },
});
// → body `{"total":"9007199254740993"}`

b.render.stream(res, iterable, opts?) #

stable0.18.19
{
  status:   200,        // numeric HTTP status, sent with the first chunk
  headers:  {},         // merged over the dynamic-response defaults
  onError:  "destroy",  // "destroy" (default) or "rethrow" to leave the socket alone
  signal:   AbortSignal, // stop producing when it aborts
}

Write an async (or sync) iterable to a response, one chunk at a time, and end it. For a generated download — a CSV export, an NDJSON dump, a receipt — where the source is a generator over a cursor rather than a Readable that could simply be piped.

The obvious loop is wrong in three ways that testing does not surface. res.write() returning false is easy to discard, and a local client drains instantly, so a bounded-memory export becomes unbounded only under a slow client. Always awaiting 'drain' then hangs forever when the peer has gone, because a closed socket never emits it. And a producer that throws after the first byte cannot be turned into an error page: the status line is already sent, so the handler appends its message to the partial body and the client receives a 200 whose last row reads "Internal Server Error" — a truncated export that every consumer reads as complete.

So: back-pressure is awaited, a closed peer stops the loop instead of stalling it, and a mid-stream throw destroys the connection. Destroying ends a chunked response without its terminating chunk, which is the only signal left that says "this transfer is incomplete" once bytes are on the wire. The error is re-thrown either way, so the caller still logs it.

A producer that fails BEFORE yielding anything is not a truncated export — nothing was produced. Opening an async generator runs none of its body, so the first value is fetched while the status line is still unsent: a query that fails to run reaches the caller with a response it can still render an error page on, rather than as a download that dies partway. One row is therefore fetched before the headers go out, which is what any implementation must do to know whether the producer can produce at all.

That wait belongs to a response THIS call commits. A long-lived stream whose first value is minutes away — an event subscription, a tail — should send its own head first (res.writeHead(...), res.flushHeaders()) and then stream into it: with the status line already out there is nothing to hold back, so no value is fetched before the headers and the client sees the connection establish at once.

Headers are written from opts before the first chunk unless the caller has already sent them. The application/octet-stream default steps aside for a Content-Type the route already set with res.setHeader — unlike the other helpers this one does not encode the body, so that default is a placeholder for an answer the caller has, and a route serving a CSV had already given it. An explicit opts.headers entry still wins over both. The Cache-Control default steps aside for that entry, and a Cache-Control with no-store already on the response replaces both of them. Nothing is buffered: a chunk is handed to the socket as the producer yields it.

Pass a function instead of an iterable to be handed a signal that aborts when the client goes away, the caller aborts, or the stream fails. Canceling a generator does not reach work it is already waiting on — a generator parked in await query() runs its finally only once that query returns on its own — so a producer holding a cursor, a connection or a file handle should take the signal and cancel with it.

await b.render.stream(res, rows(), {
  headers: { "Content-Type": "text/csv; charset=utf-8" },
});

async function* rows() {
  yield "order_id,total\n";
  for await (var r of cursor) yield r.id + "," + r.total + "\n";
}

// Taking the signal lets the query itself be canceled when the client
// hangs up, instead of running to completion against a closed socket.
await b.render.stream(res, function (signal) { return rows(signal); }, {
  headers: { "Content-Type": "text/csv; charset=utf-8" },
});

b.render.text(res, body, opts) #

stable0.1.0
{
  status:  200,
  headers: {},
  charset: "utf-8",
}

Coerces body to a string and writes it as text/plain with the supplied charset (default utf-8). null / undefined body becomes the empty string rather than the literal text "null" — a common gotcha when forwarding a value-or-nothing handler result.

b.render.text(res, "OK");
// → 200, Content-Type "text/plain; charset=utf-8", body "OK"

b.render.htmlString(res, htmlBody, opts) #

stable0.1.0
{
  status:  200,
  headers: {},
  charset: "utf-8",
}

Writes a pre-rendered HTML string with Content-Type: text/html; charset=. Use when an HTML body is already in hand — for engine-bound view rendering, prefer b.render.create({ engine }) and the returned html(res, viewName, data) helper which threads res.locals (CSP nonce, request id, current user) into the view.

b.render.htmlString(res, "

Hi

"); // → 200, text/html; charset=utf-8, body "

Hi

"

b.render.redirect(res, location, opts) #

stable0.1.0
{
  status:  302,   // 301 / 302 / 303 / 307 / 308
  headers: {},
}

Sends a 3xx response with the given Location header and an empty body. Throws when location is empty or when opts.status falls outside the 300 to 399 range. Default status is 302; pass 301 / 303 / 307 / 308 for the other RFC 9110 §15.4 redirect semantics. For untrusted user-supplied destinations, validate first via b.safeRedirect before passing the result here.

b.render.redirect(res, "/login", { status: 303 });
// → 303, Location "/login", empty body

b.render.create(opts) #

stable0.1.0
{
  engine: ,   // a template engine instance from b.template.create({ viewsDir })
}

Binds a template engine to a renderer and returns the module-level helpers (json / text / htmlString / redirect) plus html(res, viewName, data?, opts?). The html helper auto-merges res.locals into the template data so request-scoped values (CSP nonce, request id, current user) thread through every render without per-route plumbing. Operator-supplied data keys take precedence over locals — explicit beats implicit. Throws when opts.engine.render is not a function.

// requires: a views directory on disk
var engine = b.template.create({ viewsDir: "/srv/views" });
var r      = b.render.create({ engine: engine });
r.html(res, "home", { user: "ada" });
// → 200, text/html; charset=utf-8, body = engine.render("home", merged-locals)

Last updated 2026-10-12T14:21:56.879Z by seeder.