-
Notifications
You must be signed in to change notification settings - Fork 0
bundler
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);| 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}. |
Two kinds, split by whether they may change the answer.
Observers — onBundleStart, 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.
Transforms — processResult, 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
idunresolved, so a forgottenreturndegrades to a no-op rather than to a hung client. - A throwing
processResultturns that part into a synthetic 500 — theidis preserved so the waiter still resolves, and sibling parts ship normally. - A throwing
processBundlehas 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. -
processResultruns before the base64 sort, so a transform that turns a part binary still sorts last and preserves the compression-window locality.processBundleruns 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.
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 defaultThree 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
Acceptentry, so+jsonnever 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.
-
Methods:
PUTandPOST; anything else → 405 withAllow: 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;authorizationandcookiepropagate 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, andset-cookieheaders 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: trueparts while siblings proceed. -
The envelope is
Cache-Control: no-store: caching belongs to the parts, never to the envelope as a unit.
-
createBundler(options)— the factory. -
REQUEST_MIME/BUNDLE_MIME— the two protocol content types.
See also: Node & Express adapter, Koa adapter, Wire format.
Start
API
Protocol
Project