Skip to content

Concepts: pluggable envelopes

Eugene Lazutkin edited this page Jul 10, 2026 · 1 revision

Concepts: pluggable envelopes & types

Many services do not speak bare JSON: legacy APIs wrap every payload in an envelope (SOAP, JSON-RPC, a vendor {status, result} shape) and signal failures inside it. double-meh does not force problem+json — or any standard — on such services. Instead it exposes a small set of composable seams, and an envelope plugin is just those seams registered together, conventionally shipped as an install function (installX(io, options?) — the same shape as installZlibEncoders, installSW, installChannel).

The seams

Seam Direction Registration
MIME alias request io.mimeTypes.acme = 'application/vnd.acme+json' — makes as: 'acme' set the Content-Type.
Data processor request io.registerData({match(data, options), encode(data, headers, options)}) — wraps the outgoing body; runs before the JSON fast path and may set its own headers.
MIME processor response io.registerMime({match(contentType, response), decode(response, options)}) — parses/unwraps by content type; runs on success and error bodies alike (an error's output feeds BadStatus.problem).
Response inspector response io.inspect.response(fn, match?) — post-decode envelope rework; the tool when the service uses a shared MIME type (plain application/json) that must not be hijacked globally.
BadStatus.problem errors the parsed error envelope, whatever the service calls it — see the envelope page.

Everything downstream composes untouched: unwrapped payloads cache, dedupe, retry, and paginate like any other data.

A complete plugin

const ACME_MIME = 'application/vnd.acme+json';

export const installAcme = io => {
  io.mimeTypes.acme = ACME_MIME;
  io.registerData({
    match: (_data, options) => options.as === 'acme',
    encode: (data, headers) => {
      headers.set('Content-Type', ACME_MIME);
      return JSON.stringify({v: 1, payload: data});
    }
  });
  io.registerMime({
    match: contentType => contentType.startsWith(ACME_MIME),
    decode: async (response, options) => {
      const doc = JSON.parse(await response.text());
      // an envelope-level fault rides a 2xx: reject it like a bad status, fault as the data
      if (doc.fault) throw new io.BadStatus(response, doc.fault, undefined, options);
      return doc.payload;
    }
  });
  return io;
};
installAcme(io);

await io.post('/acme/echo', {name: 'unit-1'}, {as: 'acme'}); // sends {v: 1, payload: {...}}
const units = await io.get('/acme/units'); // callers see the payload, not the envelope

Fault patterns

  • HTTP-level — a non-2xx with a typed body: nothing to do. The MIME processor's decode runs on the error body, and the result lands on BadStatus.data and BadStatus.problem automatically.
  • Envelope-level — a fault member inside a 2xx (the SOAP/JSON-RPC habit): throw io.BadStatus(response, fault, undefined, options) from the decode. IOErrors pass through the error mapping unwrapped, so callers get the same instanceof io.BadStatus handling as a real 4xx/5xx, with the transport status preserved and the fault on .data/.problem. One nuance: a 2xx fault envelope is cacheable like any 2xx, so repeats throw consistently until the entry is invalidated — servers that fault inside 2xx should send Cache-Control accordingly, and the plugin's callers can pass cache: false where that bites.

SOAP and XML

An XML envelope needs an XML parser, which double-meh deliberately does not ship (zero dependencies; browsers have DOMParser, CLIs do not). The seams above are the integration surface: a SOAP plugin is a data processor building the request envelope, a MIME processor matching application/soap+xml / text/xml that parses and unwraps Body — and throws BadStatus on Fault — plus an as alias. It is planned as a separate project on this surface.

See also

Clone this wiki locally