Skip to content

bundler

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

Bundler core

createBundler(options) returns a web-standard fetch handler (Request) => Promise<Response> implementing the server side of the wire format. Platform-pure — no node: imports — so the same function serves Bun.serve, Deno.serve, service workers, edge runtimes, and (through adapters) callback servers.

import {createBundler} from 'double-meh-bundler';

const bundler = createBundler({
  isUrlAcceptable: url => url.startsWith('/api/'),
  resolveUrl: url => new URL(url, 'http://api.internal:8080').href
});

Deno.serve(bundler);

Options

Option Default Meaning
isUrlAcceptable(url, request) Required; the security boundary. The constructor throws without it. An unacceptable part becomes a synthetic 403 part — the bundle proceeds.
resolveUrl(url) identity Maps public part URLs to internal ones. The result resolves against the bundler request's own URL, so relative outputs work.
fetch(request) globalThis.fetch The upstream fetch — inject it for tests, mocks, or in-process serving.
maxRequests 20 Hard cap on parts per bundle; above it the whole request is a 400.
partTimeout 10000 Per-part upstream timeout, ms. A hung upstream becomes a synthetic 504 part. The timeout is raced explicitly, so it holds even when an injected upstream ignores AbortSignal.
streaming true Serve +jsonl when the request's Accept names it. Inert unless a client asks; never applies when processBundle is set. See Streaming.
onBundleStart(context) Observer: the bundle passed its guards and is about to fan out. Context: {request, parts}.
onItemFinish(part, context) Observer: one part reached its final shape. Context: {request, requestPart, durationMs}.
onBundleFinish(bundle, ctx) Observer: the envelope reached its final shape. Context: {request, durationMs} for the whole bundle.
processResult(part, context) Transform: rewrite one finished part. Same context as onItemFinish.
processBundle(bundle, ctx) Transform: rewrite the whole {v: 1, parts} envelope. Context: {request}.

Hooks

Two kinds, split by whether they may change the answer.

ObserversonBundleStart, onItemFinish, onBundleFinish — are for metrics and logging. They are not awaited, and a throw or a rejected promise is swallowed: instrumentation never changes behavior, so a broken counter cannot fail a bundle and a slow logger cannot delay one.

const bundler = createBundler({
  isUrlAcceptable,
  onBundleStart: ({request, parts}) => metrics.count('bundle.parts', parts.length),
  onItemFinish: (part, {durationMs}) =>
    metrics.timing('bundle.part', durationMs, {status: part.status}),
  onBundleFinish: (bundle, {durationMs}) => metrics.timing('bundle', durationMs)
});

They fire in a fixed order per bundle: onBundleStart, then one onItemFinish per part (in fan-out completion order, not request order), then onBundleFinish.

The two durationMs values answer different questions. On onItemFinish it is the upstream fetch time, measured without processResult — a clean upstream-latency signal. On onBundleFinish it is the whole bundle, from the protocol guards passing to the finished envelope, so it includes the fan-out and both transforms.

onBundleStart fires only once a bundle exists — never on a 400/405 guard rejection. onBundleFinish fires only once an envelope exists, so it stays silent when processBundle throws: a start with no matching finish is the failure signal, which is also what an observer that itself throws will not disturb.

TransformsprocessResult, processBundle — are awaited and may replace what ships.

const bundler = createBundler({
  isUrlAcceptable,
  processResult: (part, {requestPart}) => (part.status === 200 ? redact(part) : part),
  processBundle: bundle => ({...bundle, servedBy: process.env.HOSTNAME})
});
  • A nullish return keeps the original value. That is deliberate: a dropped part would leave the client's waiter for that id unresolved, so a forgotten return degrades to a no-op rather than to a hung client.
  • A throwing processResult turns that part into a synthetic 500 — the id is preserved so the waiter still resolves, and sibling parts ship normally.
  • A throwing processBundle has no per-part isolation to fall back on: the bundle fails with a 500 problem+json, and the consumer's error message stays server-side.
  • processResult runs before the base64 sort, so a transform that turns a part binary still sorts last and preserves the compression-window locality. processBundle runs after the sort and owns whatever ordering it produces.

Every hook is validated at construction: supplying a non-function throws a TypeError naming the option — a mistyped hook name would otherwise be silently inert.

Streaming

When a client's Accept names application/vnd.double-meh.bundle+jsonl, the bundler answers with a stream instead of a document: a {"v":1} header line, then one part per line, each flushed as its upstream completes. The parts themselves are unchanged — see Wire format § Streamed framing for the shape and the compression trade-off (per-part flushing costs +25% bytes at 10 parts, +57% at 50).

It is on by default but entirely client-driven: a client that never asks never gets it.

const bundler = createBundler({isUrlAcceptable}); // streaming: true is the default

Three things override it, all silently falling back to the buffered +json:

  • streaming: false — the operator's off switch, when bytes matter more than time-to-first-part.
  • processBundle — that transform needs the whole envelope, which a stream does not have. Skipping a configured transform would be worse than not streaming (it may be redacting), so the transform wins.
  • A client that does not name the jsonl type. Matching is an exact essence comparison per Accept entry, so +json never opts in by accident — it is a string prefix of +jsonl.

Hooks behave the same either way, with one difference worth knowing: onItemFinish fires as each part is written to the stream, and onBundleFinish fires after the last line with the parts in the order they shipped.

Behavior

  • Methods: PUT and POST; anything else → 405 with Allow: PUT, POST.
  • Fan-out: all parts run in parallel; sub-requests are GET-only (a non-GET part is refused synthetically, per part).
  • Headers in: each part's whitelist (accept, accept-language, if-none-match, if-modified-since, case-insensitive) rides its sub-request; authorization and cookie propagate from the outer request only — they never appear inside part JSON.
  • Headers out: parts carry their cache-relevant headers (etag, vary, cache-control, content-type, …); wire-form, hop-by-hop, and set-cookie headers are stripped.
  • Failure granularity: one bad upstream never fails the bundle — allow-list refusal (403), unresolvable URL (400), upstream failure (502), timeout (504), and non-GET (405) all become synthetic: true parts while siblings proceed.
  • The envelope is Cache-Control: no-store: caching belongs to the parts, never to the envelope as a unit.

Exports

  • createBundler(options) — the factory.
  • REQUEST_MIME / BUNDLE_MIME — the two protocol content types.

See also: Node & Express adapter, Koa adapter, Wire format.

🔍 Search the wiki

double-meh-bundler

Start

API

Protocol

Project

Clone this wiki locally