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.
Unreleased
Section titled “Unreleased”Action required
Section titled “Action required”-
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/createChannelAcceptorpaths must now pass{ scope: "instance" }, matching whatserveChannelalready 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 asnew 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.runand handlerctx.runnow honor the suppliedtimeoutoption. -
serveWirereleases restored connections and carrier resources if host activation fails. -
The frozen
0.95.0compatibility 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 anidentitypassed in its options bag instead of silently ignoring it. UseserveChannel(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_connectidprotocol_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 generichandshake_rejectedbehavior. -
Runtimes passed to
serveChannel,serveChannels,serveHost,serveWorker(s), orserveDurableObjectmust 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 withruntime.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 asserver.runtime. The old positional overload remains as a deprecated migration shim; an explicitly supplied runtime is borrowed and must be instance-scoped. -
Remove
clientEnvfrom serving/acceptor options andtargetPeerfrom action execution options. Return routing follows the connection on which a request arrived; explicit local selection usesruntime.run(request). -
Replace
transports: [{ carrier, secure }]with directcarriers: [wsCarrier(url), httpCarrier(url, { secure: false })]. The old descriptor input remains as a deprecated adapter for this release; passing both forms throwsconflicting_connect_options. Carrier URL callbacks, custom cache keys, availability predicates, labels, and per-action dial context remain supported. -
Rename connection lifecycle options to
reconnectandheartbeat. The deprecatedkeepLinkAliveandlinkKeepalivealiases retain their behavior for this release; old and new names cannot be combined in one call. -
Rename the Cloudflare realm composition helper to
createRealmHostand its return type toIRealmHost. The oldserveRealmDurableObjectandIRealmDurableObjectHostnames remain as deprecated aliases on the same subpath. The host still composes withserveWireDurableObjector action’sserveDurableObject; it does not serve a socket by itself. -
Secure connections now verify that the endpoint advertises the selected peer’s
envIdand, when explicitly present,perId. A mismatch raisespeer_identity_mismatch, stops carrier fallback, and parks redial until an explicit retry. The per-bootinsIdis intentionally ignored. -
ephemeralIdentity: truenow permits omittingstorage; a suppliedlinkorstoragestill 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.
What is new
Section titled “What is new”- @nice-code/wire exports
WIRE_PROTOCOL_VERSIONandWIRE_SUPPORTED_PROTOCOL_VERSIONS. The existing action/wire/realm format andnice-ws-hs/1transcript 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_requiredwhen 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. ActionRuntimenow has explicitglobalandinstancescopes, boundruntime.run(request), and terminal idempotentdispose(). 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
ctxargument withrun, origin, authenticated-origin, security-level, and reliability facts.channelCasesextends the same context with connection state and push helpers. - Runtime instance ids and handler ids are lazy.
serveWorkernow teaches module-scopeidentityandhandlers: [...]values while preserving runtime/handler factories as compatibility inputs; server construction and identity provisioning remain first-request lazy and retry after provisioning failure. serveWireis the platform-neutral server counterpart ofcreateWireClient. 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.createRealmHostrejects 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.persistentIdand.instanceIdare readable aliases. The own fields and serialized wire shape remainperIdandinsId.- Peer-facing handles now use
peer. DeprecatedpeerClient,peerCoordinate, andruntimeCoordinatealiases remain for migration. @nice-code/action/testingaddscreateTestChannel(channel, handlers), an encoded asynchronous loopback with typed cases, frame counters, and deterministic disposal.@nice-code/wire/advancedis the supported import for protocol, handshake, crypto, and reliable delivery primitives. Existing root exports remain compatible for this release. See the public API inventory.
0.97.0
Section titled “0.97.0”Commander web UI polish. Only @nice-code/commander changes; nothing to do on upgrade.
- A suite’s
downnow mirrors itsup. Running a suite brings its dependencies up with it, and stopping it used to leave them running —downon 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.
Action required
Section titled “Action required”groupswas removed fromcommander.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
suitesunchanged (where/include/exclude/staggerMskeep 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 shutdownonce 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. defaultSelectiontakes a process id, a suite name, or onetag=valueterm.server.pushToClientfailures are now typed. Pushing to a client with no live connection throwserr_nice_transportidnot_found(it was a plainError), and a push whose send fails rejects with idsend_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. Oneerr_nice_transport.isExact(e)guard now coversnot_found,send_failedandtimeout. See Bi-directional.write_before_hydratehas 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, orpendingExpiryMspassed. Nothing was sent in either case. A refused attach rejects queued writes withrealm.attachError. Code that awaitswhenLive()before writing behaves as before.
What is new
Section titled “What is new”- Multi-value tags —
tags: { project: ["api", "billing"] }matches both. Every existing single-value tag and selector keeps its meaning. - Suites carry their own
parallel/failFast/staggerMs/description, sorun verifyand the UI’s ▶ behave identically.explain <suite>prints the policy. optIn: truekeeps a process out of every bulk start — tag selections, a suite’swhere, and--all. It starts only by exact id, an explicit suiteinclude, or as a dependency.- CLI:
--anyORstag=valueterms (they still AND by default);@nameselects 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
blockedrow that links to its blocker; a⌘Kgo-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 withrealm.avatar.persistentIdwithout carrying the id around separately. See Connecting & React.- Write to a realm straight after
connectRealm.realm.update()andrealm.intents.*()called before the first hydrate are queued. They run in call order against the hydrated state, right afterassertOnAttach, and settle like any other write. You still needwhenLive()before reading the store.realm.pending.countincludes queued calls. createNiceServerDevtoolsacceptssessionandallowRemoteCommands, so a deployed staging backend can join a relay session and keep its runtime-derived identity. With asessionand nostage, the backend’s env is the session’s. See Remote Devtools Sessions.ReliableLog.contiguousEntries(streamId, afterSeq?)andReliableLog.pruneThrough(streamId, through), plus theIReliableLogEntry<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().deleteStreamremoved 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 callclearTransportCache()orreleaseLink(), which also suppress the automatic redial.- Restarting a process no longer restarts its dependencies.
restart webused to stop the whole dependency closure, bouncingapiunder 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.
0.91.0
Section titled “0.91.0”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:idroute 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.
0.90.0
Section titled “0.90.0”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 defaulthttpFallback: "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
curland 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.
0.89.0
Section titled “0.89.0”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.
0.87.0
Section titled “0.87.0”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_unreachableon afetchrejection (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
200with a login page. err_nice_transport’sinitialization_failedcontext gainedendpoint(the URL that transport actually dialed) andkind, anEWireConnectFailureKinddiscriminator.- New:
classifyConnectFailure(error)andendpointFromConnectFailure(error).classifyConnectFailureandEWireConnectFailureKindare 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.
Before 0.87.0
Section titled “Before 0.87.0”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.