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.

  • Security — result routing now follows the connection a request arrived on. An unauthenticated exchange (HTTP) request that merely named another client’s runtime coordinate could make the server push its result frame down that client’s live socket. Such a request answers inline on its own response. Duplex replies now use the original live connection and binding generation; a dropped, reused or reidentified socket cannot receive an older request’s result. Keep a request on the socket that should receive its reply; use explicit pushes for separate delivery.

  • Servers built through the advanced acceptChannel / createSecureChannelAcceptor / createChannelAcceptor paths must now pass { scope: "instance" }, matching what serveChannel already required. Binding a serving acceptor to a global runtime made that server a global dispatch target — the isolate-sharing hazard the scope rule exists to prevent. Construct server runtimes as new ActionRuntime(identity, { scope: "instance" }).

  • Reliable replies retain their own receiver-stamped stream key, including when the same client reuses an action correlation id across independent streams. A dropped buffered arrival retains its historical security facts while releasing its socket reference.

  • Request dispatch, runtime.run and handler ctx.run now honor the supplied timeout option.

  • serveWire releases restored connections and carrier resources if host activation fails.

  • The frozen 0.95.0 compatibility gate now exercises real action/realm runtime flows, reconnects, encrypted socket restoration, realm snapshots, reliable buffers and HTTP session tickets in both upgrade directions. Production rollout still requires the deployed application matrix.

  • The deprecated positional serveChannel(runtime, channel, options) form now rejects an identity passed in its options bag instead of silently ignoring it. Use serveChannel(channel, { identity, … }).

  • Keep every @nice-code/* dependency in each build on one lockstep package version. Before a rolling deployment, run the old/new compatibility matrix with the same action definitions and realm schemas used in production. Test deployed/deployed, deployed/candidate, candidate/deployed, and candidate/candidate; prefer a server-first canary once both mixed pairs pass.

  • A secure peer with no compatible protocol generation now fails with typed err_nice_wire_connect id protocol_version_mismatch. That failure stops carrier fallback and parks automatic redial until an explicit retry, so it cannot silently reach a plain HTTP fallback. Branch on the id rather than its message. Legacy peers whose reject has no id retain the generic handshake_rejected behavior.

  • Runtimes passed to serveChannel, serveChannels, serveHost, serveWorker(s), or serveDurableObject must now be constructed with { scope: "instance" }. This prevents server instances sharing an isolate from participating in untargeted global dispatch. Global runtimes auto-register when handlers are added; remove obsolete .apply() calls. When several global runtimes can handle an untargeted action, target one explicitly with runtime.run(request).

  • Migrate server construction to serveChannel(channel, { identity, ... }), serveChannels(channels, { identity, ... }), and the equivalent host/Cloudflare forms. The server creates an instance runtime and exposes it as server.runtime. The old positional overload remains as a deprecated migration shim; an explicitly supplied runtime is borrowed and must be instance-scoped.

  • Remove clientEnv from serving/acceptor options and targetPeer from action execution options. Return routing follows the connection on which a request arrived; explicit local selection uses runtime.run(request).

  • Replace transports: [{ carrier, secure }] with direct carriers: [wsCarrier(url), httpCarrier(url, { secure: false })]. The old descriptor input remains as a deprecated adapter for this release; passing both forms throws conflicting_connect_options. Carrier URL callbacks, custom cache keys, availability predicates, labels, and per-action dial context remain supported.

  • Rename connection lifecycle options to reconnect and heartbeat. The deprecated keepLinkAlive and linkKeepalive aliases retain their behavior for this release; old and new names cannot be combined in one call.

  • Rename the Cloudflare realm composition helper to createRealmHost and its return type to IRealmHost. The old serveRealmDurableObject and IRealmDurableObjectHost names remain as deprecated aliases on the same subpath. The host still composes with serveWireDurableObject or action’s serveDurableObject; it does not serve a socket by itself.

  • Secure connections now verify that the endpoint advertises the selected peer’s envId and, when explicitly present, perId. A mismatch raises peer_identity_mismatch, stops carrier fallback, and parks redial until an explicit retry. The per-boot insId is intentionally ignored.

  • ephemeralIdentity: true now permits omitting storage; a supplied link or storage still takes precedence. Persistent identities continue to require durable crypto storage.

  • Two different local executors at the same route specificity now fail setup with duplicate_action_executor. Keep action-specific-over-domain fallback composition, but remove accidental same-key shadows.

  • @nice-code/wire exports WIRE_PROTOCOL_VERSION and WIRE_SUPPORTED_PROTOCOL_VERSIONS. The existing action/wire/realm format and nice-ws-hs/1 transcript are protocol v1, and this release supports [1]. This metadata adds no per-message bytes and does not change handshake signatures, key derivation, frame layouts, dictionaries, schema hashes, or persisted formats.
  • Socket-only action context operations now fail with connection_required when a request arrived over HTTP or its exact socket binding is no longer live. Receiver connection and reliability metadata are installed locally and cannot be forged through action JSON.
  • ActionRuntime now has explicit global and instance scopes, bound runtime.run(request), and terminal idempotent dispose(). Supplied server runtimes remain caller-owned; each server releases only its own handler leases when disposed. Untargeted ambiguity, disposed use, invalid server scope, and peer-handler double binding have stable typed errors.
  • Every local handler style receives a runtime-bound second ctx argument with run, origin, authenticated-origin, security-level, and reliability facts. channelCases extends the same context with connection state and push helpers.
  • Runtime instance ids and handler ids are lazy. serveWorker now teaches module-scope identity and handlers: [...] values while preserving runtime/handler factories as compatibility inputs; server construction and identity provisioning remain first-request lazy and retry after provisioning failure.
  • serveWire is the platform-neutral server counterpart of createWireClient. It hosts duplex wire carriers and frame protocols without importing action. Realm acceptors receive a trusted per-connection protocol context containing the authenticated client coordinate and negotiated security level, including after a hibernation wake.
  • Realm definitions preserve their literal id. createRealmHost rejects a mismatched literal record key at compile time and preserves each hosted engine’s state, avatar, and server-context types. The runtime key/id check remains for JavaScript and dynamic records.
  • RuntimeCoordinate.persistentId and .instanceId are readable aliases. The own fields and serialized wire shape remain perId and insId.
  • Peer-facing handles now use peer. Deprecated peerClient, peerCoordinate, and runtimeCoordinate aliases remain for migration.
  • @nice-code/action/testing adds createTestChannel(channel, handlers), an encoded asynchronous loopback with typed cases, frame counters, and deterministic disposal.
  • @nice-code/wire/advanced is the supported import for protocol, handshake, crypto, and reliable delivery primitives. Existing root exports remain compatible for this release. See the public API inventory.

Commander web UI polish. Only @nice-code/commander changes; nothing to do on upgrade.

  • A suite’s down now mirrors its up. Running a suite brings its dependencies up with it, and stopping it used to leave them running — down on a two-member stack left the three services underneath it alive. It now stops the whole dependency closure, except a dependency that some other live process still needs, which is left running and named in the report. Hover the button to see exactly what it will stop. Stopping a single process, or a tag group, is unchanged: only what you named.
  • Process rows have a “stop with dependencies” action — the same rule, for one process: it appears only when it would stop more than the process itself, and its tooltip lists what goes and what stays.
  • Row actions no longer float over the row’s text. They are compact icons in their own space at the right of the row, showing only what applies — start for a stopped process; restart, stop and stop-with-dependencies for a live one — dim until you hover or focus the row.
  • The tag filter’s any | all toggle is always visible, and the process list has its own heading carrying the count and group by.

Commander: tags organise, suites run — groups is gone. The web UI is rebuilt around tag groups, suites and saved filters. Realm, action, wire and devtools also get small additions that make first examples shorter and failures easier to catch.

  • groups was removed from commander.config.ts. A config that still has the key fails validation — and the error is the migration guide: for each group it names what it becomes.
    • one member → delete it and use the process id;
    • exactly one tag selection (project=x) → delete it and select by tag (pin it in the UI);
    • anything else → move it under suites unchanged (where / include / exclude / staggerMs keep their meaning).
  • A suite must resolve to at least two members. A one-member suite is an error that names the id to run instead.
  • Run nice-commander shutdown once after upgrading (it stops managed processes). The daemon protocol moved to v6, so a CLI from this release will not talk to a daemon from the last.
  • defaultSelection takes a process id, a suite name, or one tag=value term.
  • server.pushToClient failures are now typed. Pushing to a client with no live connection throws err_nice_transport id not_found (it was a plain Error), and a push whose send fails rejects with id send_failed, keeping the original error as the cause (it was the raw carrier error). Only code that matched on the old message text needs changing. One err_nice_transport.isExact(e) guard now covers not_found, send_failed and timeout. See Bi-directional.
  • write_before_hydrate has a narrower meaning. A realm write made before the first hydrate is now queued instead of failing, so this id is raised only when the hydrate never comes: the handle was detached, or pendingExpiryMs passed. Nothing was sent in either case. A refused attach rejects queued writes with realm.attachError. Code that awaits whenLive() before writing behaves as before.
  • Multi-value tagstags: { project: ["api", "billing"] } matches both. Every existing single-value tag and selector keeps its meaning.
  • Suites carry their own parallel / failFast / staggerMs / description, so run verify and the UI’s ▶ behave identically. explain <suite> prints the policy.
  • optIn: true keeps a process out of every bulk start — tag selections, a suite’s where, and --all. It starts only by exact id, an explicit suite include, or as a dependency.
  • CLI: --any ORs tag=value terms (they still AND by default); @name selects a tag group pinned in the web UI.
  • Web UI: an any/all tag filter with per-chip counts; results bucketed into tag groups by the exact tags they share, each runnable from its heading with a preview of what will start; recent runs recorded and tag groups pinnable, stored with the daemon so they survive browsers and sessions; suite cards with per-member progress, run duration and rerun failed; live dependency dots on every row, hover tracing of upstream/downstream rows, and a blocked row that links to its blocker; a ⌘K go-to palette. See the web UI.
  • realm.avatar — a realm handle now exposes the identity it attached as (type, persistentId, instanceId), so you can key your own entries with realm.avatar.persistentId without carrying the id around separately. See Connecting & React.
  • Write to a realm straight after connectRealm. realm.update() and realm.intents.*() called before the first hydrate are queued. They run in call order against the hydrated state, right after assertOnAttach, and settle like any other write. You still need whenLive() before reading the store. realm.pending.count includes queued calls.
  • createNiceServerDevtools accepts session and allowRemoteCommands, so a deployed staging backend can join a relay session and keep its runtime-derived identity. With a session and no stage, the backend’s env is the session’s. See Remote Devtools Sessions.
  • ReliableLog.contiguousEntries(streamId, afterSeq?) and ReliableLog.pruneThrough(streamId, through), plus the IReliableLogEntry<T> type, for replaying and pruning a push log without reconstructing sequence numbers. See Reliable Delivery.
  • Devtools window: side-by-side clients are now called columns in the UI. “Lane” means only a traffic category. Stored layouts are unaffected.
  • The in-memory reliable log store now deletes a forgotten stream’s frames. createMemoryReliableLogStore().deleteStream removed only the high-water mark. This store is for tests and development; the SQL store was already correct.
  • WireClient.clearCache() is marked @internal. It is unchanged, but apps should call clearTransportCache() or releaseLink(), which also suppress the automatic redial.
  • Restarting a process no longer restarts its dependencies. restart web used to stop the whole dependency closure, bouncing api under every other process that depended on it. Only the ids you name are stopped; a dependency that is already ready is left alone.
  • Starting many tasks from the web UI is now bounded and dependency-gated. It used to launch every selected task at once. UI runs now go through the same scheduler as run --parallel.

Several duplex connections from one runtime to one peer. A new connectChannel option — additive, nothing to change unless you hit the case below.

Vibenode reported a silent misroute: a web app dialling one backend over two WebSockets (a per-scope store socket and a jobs socket) had every jobs reply and progress push delivered to the store socket, where nothing was listening. The bulk run looked like it hung until the transport timeout fired.

The cause was one identity across two links. An acceptor binds each connection by the coordinate that connection presents, and every connection of a runtime presented the runtime’s own coordinate — so with both sockets live the server could not tell them apart, and the return path picked whichever bound first. The only workaround available was a second ActionRuntime minted purely to obtain a second instance id, which contradicts the “one runtime per app” rule the documentation states.

connectChannel now takes a connectionId:

connectChannel(runtime, storeChannel, { peer: backend, connectionId: "store", … });
connectChannel(runtime, jobsChannel, { peer: backend, connectionId: "jobs", … });

The label is folded into the runtime’s per-boot instance id, not substituted for it, so:

  • the environment and persistent id are untouched — the server still sees one client, one trust-on-first-use pin, one set of permissions;
  • a stable label stays safe for reliable delivery, which needs a fresh-per-boot stream epoch. Each named connection also gets its own reliable streams, closing a latent case where two connections sharing a domain:id route would have merged two independent sequence counters into one inbox stream.

The identity a named connection presents in its handshake and the origin it stamps on its frames are now the same value by construction, so the secure and plain paths agree on which socket a return belongs to.

connectChannel also warns at dial time when a second connection to the same peer reuses an identity already in use, naming connectionId — the misroute is no longer silent for anyone who hasn’t read this entry. Two channels that can share one socket still want connectChannels([a, b]); connectionId is for transports that genuinely differ.

Action required: none. Existing single-connection apps are byte-identical — the option is opt-in and unset behaviour is unchanged.

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 httpCarrier(url, { 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.