-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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 stream — io.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.
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.
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.js → records.js edge.
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.
Guides
Concepts
Services
Modules
- fetch transport
- keys & URLs
- helpers
- records
- sse
- storage backends
- compression encoders
- Service Worker integration
Cookbook