Skip to content

Release notes

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

Release notes

This is the long-form release history for double-meh. The README carries a short summary of the most memorable items per release; this page goes into the detail that doesn't fit there — internal changes, calibration notes, related wiki / repository changes, and credits.

1.2.0 — 2026-08-08

Streamed bodies over the sw transport. A request through {transport: 'sw'} used to come back as one ArrayBuffer no matter what shape asked for it. The body is now negotiated per request: any call that sets streamio.stream.*, io.records.* — asks the worker to transfer a live ReadableStream, so records arrive as the server produces them instead of after the last byte. Parsed and envelope calls keep the buffered default deliberately: buffered is the path where the worker seeds its shared Cache API tier before it replies, and that ordering is what lets a prefetch survive a navigation. A worker that cannot transfer streams answers buffered anyway, so asking never needs a capability check, and a client that never asks sees byte-identical v1 traffic — which is why this widened the contract without moving CONTRACT_VERSION. The flag discriminates, exactly as the content type does for the bundler's +jsonl framing. Lockstep with double-meh-sw.

A malformed SSE retry: no longer storms reconnects. The field was read with Number(value), but Number('') is 0 — so a server emitting an empty retry: collapsed the reconnect delay to zero and produced 77 connections in 150 ms. The parser now accepts only ASCII digits, per the spec. The fix grew a second half during verification: a digits-only value past setTimeout's 32-bit ceiling is substituted with a delay of 1, which is the same storm by another route and a pre-existing hole that every earlier validity check passed. The final form is a digits test plus a clamp.

{transport: 'sw'} resolves relative URLs against the page. io:fetch carried whatever buildUrl produced, and buildUrl returns the URL unchanged when there is no query — so a relative URL crossed the channel relative, and the worker resolved it against its own script. Demonstrated in Chromium from /deep/page.html: fetch('api/rel') hit /deep/api/rel while the same URL through the transport hit /api/rel, silently, with a 200 — and adding one query parameter made it correct again, because that is buildUrl's absolutizing branch. The transport now sends absoluteUrl(url), newly exported from double-meh/key.js: resolution only, not canonicalUrl, which sorts the query and drops the hash — right for an identity key, wrong for a URL about to be fetched. Only the page can do this; the worker has no way to learn the page's base. The two bases coincide whenever the page and the worker script share a directory, which is why flat deployments were correct by accident and the defect hid from every existing test.

Internal. The streamed path arrived with a coverage hole worth naming: the fake worker in the test suite gated its streamed reply on the same platform probe the real worker uses, so Node and Deno only ever exercised the streamed branch and Bun (which cannot transfer a ReadableStream) only the buffered one — no runtime tested a streamed ask against a non-streaming worker, which is the design's whole safety claim. Deleting the negotiation left the entire suite green. The gate is now injectable in the double, so the fallback is deterministic everywhere, and a liveness assertion distinguishes the two paths, which are otherwise indistinguishable from the caller's side. ARCHITECTURE.md's dependency graph gained the src/sw.js → src/key.js edge it had been missing.

1.1.1 — 2026-08-08

io.paginate cannot loop forever on a stuck offset. The links arm already guarded against a repeated page with a visited set, and the cursor arm with lastCursor; the offset arm had neither, so a server that echoes a fixed offset — the shape produced by ignoring an offset parameter it does not recognize — kept the walk re-yielding the same rows indefinitely. The computed offset is now checked for strict advance and a non-advancing one throws FailedIO: io.paginate: the offset repeats a page, matching what the other two arms already did. The check compares the computed offset rather than the server's echoed one, which also catches an oscillating echo that a compare-against-previous would miss. The wiki had promised this behaviour ("throws FailedIO instead of looping forever") while naming only next and cursor, so the documentation was ahead of the code.

Paging precedence is now written down. The four shapes are tried in order per response — body links, cursor, numeric offset, then the Link response header — so an envelope carrying two signals is paged by the first that matches, and a body carrying none of the three falls through to the header rather than ending the walk. Behaviour is unchanged; only the documentation is new.

Internal. Two tests added, one of them covering the cursor repeat guard, which had shipped untested. The guard is negative-controlled: weakening its comparison hangs the suite, which is how the test is known to exercise it. Nullish guards across src/ moved to optional chaining where a typeof conjunct already excluded the falsy scalars — checked for equivalence before applying, and deliberately not applied to predicate return positions. dev-docs/design.md gained an endpoint-adapters section, with Cookbook: adapting endpoints as its task-oriented companion. Gate: 268 / 734 on Node and Bun, 272 / 738 on Deno, Chromium 239 / 540 over HTTP/1.1 and 241 / 545 over HTTP/2.

1.1.0 — 2026-08-08

Streamed bundles. The bundle service gained streaming (default false, settable globally as io.bundle.streaming or per bundler in register()). With it on, the send offers application/vnd.double-meh.bundle+jsonl alongside the buffered type, and a bundler that speaks it flushes parts as their upstreams complete — each waiter resolves the moment its own part lands rather than after the whole envelope, so one slow upstream no longer holds a window hostage. The framing is JSON Lines: a {"v":1} header line, then one part per line. Nothing else moves — part shapes, ids, caching, writeThrough, synthetic-part errors and BadStatus mapping are identical to the buffered path, and a bundler that answers +json anyway is handled transparently, losing only the early delivery.

The version did not move with the framing. Because the parts are byte-identical, bumping v would have falsely signalled a payload change and forced a version branch into every future client. v stays 1 and the content type is the discriminator — which put a hazard in the middle of the design: …bundle+json is a string prefix of …bundle+jsonl, so the existing startsWith test in the unbundling inspector would have unpacked a streamed body as a buffered one. Both sides now compare the content-type essence, and both this repo and double-meh-bundler carry a regression test for it.

It is opt-in because it costs compression. Parts only arrive early if the server flushes its compressor per part, and that flush measures +25% bytes at 10 parts, +39% at 20, +57% at 50 against one-shot gzip of the buffered envelope. Compression locality is the win that made bundling beat HTTP/2 in the first place, so this is a real trade rather than a free upgrade: stream when part latencies are uneven enough that time-to-first-part outweighs bytes. Parts also arrive in completion order, not request order — correlation has been by id since 1.0.0 precisely to allow this — and base64 parts are still held to the end so text parts stay contiguous in one compression window.

double-meh/services/bundle.js now also exports BUNDLE_JSONL_MIME. The server counterpart is double-meh-bundler's own streaming option, which declines to stream whenever a processBundle transform is configured — that transform needs the whole envelope, and silently skipping a configured transform (it may be redacting) is worse than not streaming.

Internal. Tests: 266 / 728 on Node and Bun, 270 / 732 on Deno, plus real-Chromium suites over HTTP/1.1 (237 / 534) and HTTP/2 (239 / 539). The streamed path is covered end-to-end — a wire test against the reference bundler fixture asserts a fast part resolves while a 250ms part is still upstream — alongside unit tests for negotiation, mid-stream resolution, per-part synthetic failures, missing parts, buffered fallback, and the prefix-collision regression. ARCHITECTURE.md and AGENTS.md picked up records.js and sse.js, which had been missing from the module maps since they were added, and the new services/bundle.jsrecords.js edge.

1.0.0 — 2026-08-07

The initial release. double-meh is a zero-dependency, ESM-only HTTP I/O library built on fetch, superseding the XHR-era heya/io family on a single modern core. It runs on browsers, Node, Bun, and Deno from the same source, with no build step.

One pipeline, one envelope. Every request walks prepare → request inspectors → track → services → transport (the pipeline), and every response comes back as one envelope contract shared with the error path. The return model is declared by the method you call rather than by an option: io.get() resolves to data, io.full.get() to the whole envelope, io.stream.get() to a body stream. io itself is callable, and io.create() mints an isolated instance whose error classes are still shared — one instanceof catch serves every instance.

An error taxonomy that distinguishes causes. IOError is the base; FailedIO covers transport and decode failures, BadStatus carries a non-2xx response (deliberately not a FailedIO), and TimedOut marks a deadline the caller didn't cause. Aborts pass through raw — never wrapped, never retried — and .cause is preserved throughout, so the underlying ECONNREFUSED stays reachable under a fetch failed.

Caching with real HTTP semantics. The cache service does TTL, 304 revalidation with If-None-Match / If-Modified-Since, and pattern invalidation (exact URL, trailing-* prefix, RegExp, or predicate). Request identity is Vary-aware: the effective Accept folds into the key so representations coexist, and stored entries snapshot the request headers their Vary names — a mismatch is a miss, and Vary: * is never stored. Four backends behind one interface: in-memory, filesystem (OS cache dir, atomic temp+rename), SQLite (node:sqlite / bun:sqlite), and the browser Cache API.

Deduplication that shares work without sharing fate. The track service collapses concurrent identical GETs onto one wire and hands every caller the same decoded envelope. Cancellation is refcounted: a caller's abort detaches only that caller, the wire aborts when the last waiter leaves, and an aborted caller always receives its own error rather than the wire's shutdown reason. io.adopt() seeds the cache from a response you already have — the code-forward prelude protocol builds on it.

Streaming in both directions. io.stream.get yields a response body; io.stream.put/post/patch open duplexes whose .response resolves at headers time, before either side finishes. On top sit records (JSONL and RFC 7464 json-seq) and a reconnecting SSE client with Last-Event-ID resumption. Progress is reported on both legs — onDownloadProgress per chunk, onUploadProgress metered at the innermost dispatch so it counts post-compression bytes and never fires for cache hits.

REST correctness built in. Conditional writes via io.update() with an ETag guard, Idempotency-Key generation, problem+json surfaced as BadStatus.problem (lazy, memoized, with a JSON sniff for mislabeled bodies), content negotiation through the as MIME registry, and query builders including io.paginate() (link / cursor / offset strategies, with a repeating-link guard instead of an infinite loop) and io.getByIds() (comma-joined GET, falling back to POST past a URL-length limit).

Composition instead of machinery. io.defaults(match?, bag) supplies scoped option defaults as the lowest merge layer — applied pre-key, so defaults shape the wire and cache identity. Request and response inspectors are URL-scoped. registerData / registerMime plus BadStatus.problem compose into pluggable envelopes with no new API surface. Compression encoders ride the platform CompressionStream for gzip/deflate, with opt-in br/zstd through node:zlib. The bundle service transparently batches requests into a single round trip. Arrays serialize per listSeparator, defaulting to repeated keys — the unambiguous form — with an explicit join available.

Service Worker integration. installSW(io) announces the library to a controlling worker and adds an sw message transport that reaches uncontrolled pages; installChannel(io) propagates cache invalidation across tabs over BroadcastChannel, using a connected Service Worker as the fan-out hub when there is one.

Internal. The API was deliberately reworked before publication rather than after, so 1.0.0 starts from a settled surface. Tests run on every runtime — 258 tests / 698 asserts on Node and Bun, 262 / 702 on Deno, plus real-Chromium suites over both HTTP/1.1 (230 / 511) and HTTP/2 (232 / 516), the latter covering upload streaming, which is Chromium-and-h2-only. Request-dedup and cache-tier invariants additionally carry fast-check scheduler property tests that explore task interleavings. Type-checking is two-pass (ts-check over declarations, js-check over checked JS), and the package ships its source with no build step.

Clone this wiki locally