Skip to content

Wire format

Eugene Lazutkin edited this page Aug 8, 2026 · 2 revisions

Wire format (v1)

The bundle protocol is deliberately library-independent: any client can speak it to any bundler. Detection keys on the MIME types, not on endpoints or body sniffing:

  • request: application/vnd.double-meh.bundle-request+json
  • response: application/vnd.double-meh.bundle+json
  • response (streamed): application/vnd.double-meh.bundle+jsonl — see Streamed framing

Exported as REQUEST_MIME / BUNDLE_MIME / BUNDLE_JSONL_MIME.

BUNDLE_MIME is a string prefix of BUNDLE_JSONL_MIME. Match content types by essence (type.split(';')[0].trim() === MIME), never with startsWith — a startsWith(BUNDLE_MIME) test silently accepts a streamed body as a buffered one.

Request

{
  "v": 1,
  "parts": [
    {
      "id": "a",
      "url": "/api/users/42",
      "method": "GET",
      "headers": {"accept": "application/json", "if-none-match": "\"v7\""}
    }
  ]
}
  • id — client-assigned, unique within the bundle; correlation is by id, never by order.
  • url — the request identity; the bundler's resolveUrl may map it internally, but parts echo the original.
  • method — GET only in v1 (defaults to GET; anything else is refused per part).
  • headers — a whitelist rides per part: accept, accept-language, if-none-match, if-modified-since (case-insensitive). Auth and cookies never appear inside parts — the bundler propagates authorization/cookie from the outer request to every sub-request.

The bundle rides a PUT (or POST) — a bundle of GETs is idempotent, so retrying the whole envelope is safe by construction.

Response

{
  "v": 1,
  "parts": [
    {
      "id": "a",
      "url": "/api/users/42",
      "status": 200,
      "headers": {"content-type": "application/json", "etag": "\"v8\"", "vary": "Accept"},
      "body": {"name": ""}
    },
    {"id": "b", "url": "/api/roles", "status": 304, "headers": {"etag": "\"v3\""}},
    {
      "id": "c",
      "url": "/api/broken",
      "status": 502,
      "synthetic": true,
      "headers": {"content-type": "text/plain"},
      "body": "connection refused"
    }
  ]
}

Part rules

  • Parts echo their url (plus optional accept): ids correlate waiting requests, but unclaimed parts — server-added prefetches, payloads from other endpoints — need the request identity to seed client caches.
  • Bodies by content type: JSON types ride inline (that is the compression-locality payoff); text/* as strings; binary as base64 with "encoding": "base64". 204 and 304 parts carry no body.
  • Base64 parts sort last (stable): an incompressible span between text parts would push them out of gzip's 32KB window; text-first preserves cross-part compression.
  • Part headers carry the cache-relevant set (etag, vary, cache-control, content-type, last-modified) — they drive the client's per-URL cache. Wire-form (content-encoding, content-length), hop-by-hop, and set-cookie headers are stripped.
  • synthetic: true marks parts whose upstream transport never happened (allow-list refusal 403, unresolvable 400, non-GET 405, upstream failure 502, timeout 504). Clients map synthetic parts to their transport-failure error class; a genuine upstream non-2xx part keeps its status and body and maps to the client's normal HTTP-error path.

Envelope rules

  • The response is Cache-Control: no-store: the envelope must never be cached or revalidated as a unit — caching belongs to the parts.
  • Outer-request errors are application/problem+json: 400 (malformed body, unknown version, too many parts), 405 + Allow: PUT, POST.

Streamed framing

A client that names application/vnd.double-meh.bundle+jsonl in its Accept gets the same parts, flushed as their upstreams complete instead of assembled into one document. The framing is JSON Lines: a header record, then one part per line.

{"v":1}
{"id":"a","url":"/api/users/42","status":200,"headers":{"content-type":"application/json"},"body":{"name":"…"}}
{"id":"b","url":"/api/roles","status":304,"headers":{"etag":"\"v3\""}}
{"id":"c","url":"/api/img","status":200,"headers":{"content-type":"image/png"},"encoding":"base64","body":"…"}
  • The first line is the envelope header{"v":1} — carrying what the buffered envelope's top level carries. Later envelope-level fields go here.
  • Every later line is exactly one part, in the v1 part shape above. Nothing about parts changes; only the framing does, which is why v stays 1 and the content type is what distinguishes the two.
  • Parts arrive in completion order, not request order. Correlation is by id, which the format has required from the start precisely so out-of-order delivery stays legal.
  • Base64 parts are still held to the end of the stream: the text-first locality rule outranks flushing a binary part early.
  • Order is the only guarantee that changes. A client must not assume a part count up front; the stream ends when it ends.

Negotiation is one-sided and additive: a bundler that does not implement the streamed form simply answers +json, and a client that does not ask never sees it. Both sides must match the content type by essence, not by prefix.

The compression trade

Streaming and compression pull against each other, and the numbers are not subtle. Measured on a 20-part bundle of small JSON responses through node:zlib:

bytes
buffered +json, one-shot gzip 665
+jsonl, no per-part flush 658
+jsonl, Z_SYNC_FLUSH per part 922 (+38.6%)

At 50 parts the flushed cost rises to +56.6%. Without the per-part flush gzip simply re-buffers the stream and nothing arrives early, so a compressing streamed bundler pays real bytes for early delivery — roughly the size win bundling exists to capture. Stream when part latencies are uneven enough that time-to-first-part matters more than bytes; stay buffered otherwise.

Versioning

v is the format version. Unknown versions are refused with 400 — additive evolution happens behind new content types (as the streamed …bundle+jsonl framing does) or a new v.

The normative source is the design record in the double-meh repository (dev-docs/design.md § bundle); the client implementation is double-meh's bundle transport.

🔍 Search the wiki

double-meh-bundler

Start

API

Protocol

Project

Clone this wiki locally