Skip to content

Changelog

What changed in each release, and what — if anything — you have to do about it. The migration notes Stability & Versioning points at.

Every @nice-code/* package releases in lockstep at one version, so an entry here covers the whole set. Each release says what changed and, where it matters, what you have to do — usually nothing.

While we are on 0.x, a 0.MINOR.0 may carry a breaking change. When it does, it is called out here under Action required. A 0.x.PATCH is fixes only.

Plain HTTP now actually works. 🐛 A real bug, fixed — no action needed beyond upgrading.

From 20 June 2026 until this release, a plain (unhandshaked) HTTP pairing was broken end to end: the plain connector always sent the { k: "act", w } exchange envelope, and the plain endpoint never unwrapped it. The reply direction was broken the same way. This affected:

  • serveDurableObject’s default httpFallback: "plain"
  • serveWorker({ secure: false })
  • any client on { carrier: httpCarrier(...), secure: false }

It went unnoticed for two months because the plain endpoint is normally a fallback behind a preferred WebSocket, so nothing exercised it. Secure pairings were never affected.

What changed, all inside createActionFetchHandler’s plain branch (which serveChannel, serveWorker and serveDurableObject all route through):

  • The envelope is decoded with the byte-frozen codec the secure acceptor uses, rather than a hand-rolled unwrap. An enveloped request is answered with an envelope; a raw action wire is still accepted and answered raw with its HTTP status, so curl and older peers keep working.
  • Every reply to an enveloped request is a 200 envelope, refusals included.

Action required: none — your code is already written the way the fix expects. If part of your estate pins ≤ 0.89.0, use httpFallback: "secure" or a WebSocket-only transport until you upgrade.

Worth knowing, since it was verified while fixing this: a plain client against a secure acceptor works. secure: false on the connector produces the tokenless envelope, which httpAcceptorCarrier() accepts at level none — so a read-only public client needs no identity of its own. See Security Levels and Cloudflare Durable Objects.

Release hygiene — package versions realigned after a mis-sequenced cycle, plus a fix to @nice-code/commander’s terminal output for process logs. No library behaviour changed.

Two consumer-reported issues, both behavioural. ⚠️ One changes when an existing error throws — read the @nice-code/state entry if you use Store.update.

Connection failures are typed and self-locating

Section titled “Connection failures are typed and self-locating”

Dial failures used to flatten to a message string: no endpoint, no discriminator, nothing to branch on but text. All three layers held the evidence and dropped it. Now:

  • The HTTP carrier throws with status, content-type and a sanitized body preview on a non-2xx response, and endpoint_unreachable on a fetch rejection (keeping the platform’s own wording). A deliberate abort is rethrown untouched and never mistaken for an endpoint verdict.
  • The handshake names what came back when the reply isn’t a protocol frame — which is what catches a proxy answering 200 with a login page.
  • err_nice_transport’s initialization_failed context gained endpoint (the URL that transport actually dialed) and kind, an EWireConnectFailureKind discriminator.
  • New: classifyConnectFailure(error) and endpointFromConnectFailure(error). classifyConnectFailure and EWireConnectFailureKind are re-exported from @nice-code/action, so an actions consumer needs no direct @nice-code/wire import.

Action required: none, but if you were matching on error message text to tell “server down” from “wrong URL”, replace it with a kind check — that is what it is for. See Error Handling and the kind table.

@nice-code/state — the returned-state guard is no longer data-dependent

Section titled “@nice-code/state — the returned-state guard is no longer data-dependent”

Store.update is mutate-only: an updater’s return value is ignored, and returning a replacement while leaving the draft untouched throws rather than silently doing nothing.

The old guard judged this by whether the draft happened to change, which made the throw depend on the data. store.update((draft) => Object.assign(draft, next)) passed on every run where next differed from the current state, then threw on the one reset where it was identical — in production, long after the code was written.

The guard now splits on draft identity: a returned draft always passes, a returned replacement with an untouched draft always throws. Same rule, deterministic outcome.

Action required: none. This only ever turns a spurious throw into a pass. If you added a defensive braced body ((draft) => { Object.assign(draft, next); }) to work around it, you can keep it — it stays correct. See Stores.

Not recorded here. Use the git tags, or ask — if you hit something that looks like a behaviour change between two earlier versions, we would rather answer it than have you guess.