-
Notifications
You must be signed in to change notification settings - Fork 0
Wire format
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_MIMEis a string prefix ofBUNDLE_JSONL_MIME. Match content types by essence (type.split(';')[0].trim() === MIME), never withstartsWith— astartsWith(BUNDLE_MIME)test silently accepts a streamed body as a buffered one.
{
"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'sresolveUrlmay 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 propagatesauthorization/cookiefrom 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.
{
"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"
}
]
}-
Parts echo their
url(plus optionalaccept): 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, andset-cookieheaders are stripped. -
synthetic: truemarks 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.
- 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.
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
vstays1and 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.
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.
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.
Start
API
Protocol
Project