Skip to content

Releases: pathosDev/actor-ts

v0.17.0

Choose a tag to compare

@pathosDev pathosDev released this 29 Aug 21:46
a5634ee

The dead letters, DevTools and the wire release — 828 commits since v0.16.0 and 291 changelog entries, by a wide margin the largest window this project has had (v0.16.0, the previous record, was 188). Three threads run through it. The wire — cluster frames stop being a bare JSON.stringify and become the same tagged JSON tree the persistence layer already wrote, so the framework stops contradicting itself across its own boundaries. Dead letters — an undeliverable message stops vanishing into an event stream nobody subscribed to and gets a bounded, inspectable, replayable queue. DevTools — the UI becomes something you would actually leave open, with four new panels and the ability to stop time.

Fifty-three entries carry a BREAKING marker, each with a migration note. One of them decides how you deploy this, and it is first below.

⚠️ Breaking changes (pre-1.0)

  • A rolling upgrade across this release is not safe in either direction — and this is the second consecutive release for which that is true (#450). Cluster frames are now the tagged JSON tree, applied to the whole frame rather than an envelope's body. The old format was a bare JSON.stringify, and the cost was that the framework disagreed with itself depending on which boundary a value crossed: a Map an actor could persist and recover verbatim arrived at a peer as {}, a Date arrived as a string whose .getTime() throws, a Uint8Array arrived as an index-keyed object, NaN and -0 arrived as null and 0, and a bigint threw straight out of TcpTransport.send — which is to say, out of your own ref.tell. One walker now serves HTTP bodies, journal rows and the wire alike, so there is no per-transport list of what a message may contain to keep in sync.

    Migration. Most legacy traffic decodes unchanged, because decodeJsonTree reads a tag only when it is an object's sole own key. The exception is a legacy body that already had that shape: __map__, __set__, __regexp__, __bigint__, __url__, __number__ and __error__ throw at any depth, and a decoder throw costs the whole connection along with every frame batched into the same chunk; __bytes__ and __date__ fail silently instead, decoding to a Uint8Array and an Invalid Date. The other direction is plainly lossy — an older node reads the tag wrapper as ordinary data.

    Upgrade the cluster in one step: stop every node, then start every node on the new version. v0.16.0 already broke gossip compatibility the same way (#112), so a hop from v0.15.x lands on two non-rollable legs and needs the same treatment once rather than twice. The upgrade documentation now carries a per-release compatibility table (#1304), and #823 — a protocol version handshake — is what will make a mixed-version window a supported state rather than a hazard.

  • Cluster.bootstrap rejects when readiness is missed (#943, #1086). A resolved bootstrap() now means a formed cluster. awaitReady widens to boolean | number | ClusterReadinessOptions, and its default budget covers the self-election grace on every stable-observation node — the old default under-covered N−1 of N nodes on a genuine cold start. On timeout the bootstrap runs the coordinated-shutdown pipeline and rejects with ClusterReadyTimeoutError instead of resolving for a node still joining and letting it serve traffic. Migration: awaitReady: false plus cluster.awaitReady().catch(…) restores the old fire-and-forget shape.

  • ActorCell handles a batch of user messages per dispatcher turn (#409) — worth 2.1×–3.6× on tell throughput. Configurable per actor through ActorOptions.withThroughput().

  • Three cluster-correctness defects that all had the same shape: two authorities where there should be one. A routine singleton scale-up no longer runs two instances (#949); KeepMajority now downs both sides of an exact 50/50 split rather than returning the empty set and leaving both halves live (#1170); and two nodes that disagree about numShards are no longer allowed to double-home entities silently, including via a persisted coordinator snapshot that used to route around the refusal (#633).

  • system.terminate() drains the actors under /user before stopping them (#663). ref.tell('x'); await system.terminate() now delivers x.

  • A bounded mailbox's capacity bounds the messages it may discard, not the messages it holds (#729) — and messages a bounded or priority mailbox discards can now become dead letters (#773) instead of vanishing.

  • BrokerActor.onReceive is sealed (#709). Subclasses implement the new abstract onCommand(command) instead.

  • Dead letters name the actor the message failed to reach, on every path (#433).

  • Projections gained a handler-failure recovery strategy and no longer retry a poison event forever (#650).

Forty more are in CHANGELOG.md, each with its own migration note.

🚀 New features

  • A bounded, optionally durable dead-letter queue with inspection and replay (#1000, #433). Undeliverable messages were published to an event stream that nothing subscribed to by default — which meant they produced no output at all, while two documentation pages claimed the system logged them. There is now a real queue, configured through ActorSystemOptions.withDeadLetters(…), with a metrics store option, and deadLetterQueue.replay(id, alternateRecipientPath) to replay a message to a recipient other than the one it was addressed to.

  • DevTools grew four panels and a pause button (#482, #553, #1349). The UI is Angular throughout, and gains a dead-letter panel, a live event-stream tail, a resolved-configuration panel showing every HOCON key and where its value came from, and a send-message action that is off by default. Time can be paused and resumed from the header. It also gained roughly 3,000 lines of tests, having previously had none (#487).

  • Cluster operability: wait until the cluster is actually formed (#943); ClusterOptions.advertisedHost for nodes behind NAT or a service mesh (#944); warm hand-over for singletons, so a scale-up transfers state instead of rebuilding it; and ClusterSharding.shardMap(typeName).

  • Persistence: PersistentActor can be fenced with a lease (#1166), so a stale instance cannot keep writing; PersistentActor and DurableStateActor gained an integrity() hook; InMemorySnapshotStore accepts a keepN retention bound; Postgres and MariaDB projections read the tags index instead of scanning.

  • The framework comparison is complete (#27, #1327). Nine arms across three runtimes — actor-ts, nact and XState on Bun; Akka and Pekko through both their Java and Scala APIs on the JVM; Akka.NET and Orleans on .NET — measured as a hundred interleaved rounds on one Linux machine, every row verified against work the system actually completed rather than work it was asked for. See RESULTS.md.

  • Observability: actor_mailbox_depth, actor_mailbox_wait_seconds and actor_dispatcher_queue_delay_seconds; /health and /ready aggregate framework-owned health checks; ActorSystem.runUntilTerminated() owns the whole of a service's shutdown.

  • An email bridge actor and HTML email templates (#1133).

🔒 Security

Seventy-one entries — the largest security section this project has shipped. Among them: a ProducerController now stamps a crypto-random per-incarnation token on every Delivery and the ConsumerController refuses one that does not echo it; a replicated event's author is bound to the node that sent it, so a member can no longer permanently suppress another replica's events (#706); object-storage bodies are bound to the storage key they live at, so a durable-state revision cannot silently go backwards; DistributedData gossip is bounded by a per-frame byte budget; a WebSocket route's transport frame cap is the cap you configured, in both directions (#373, #586); a decoded CRDT counter slot is bounded; and the object-storage backends and the master-key rotation sweep now share one key policy (#747).

🐛 Fixed

Ninety-one entries. The tooling half is worth calling out on its own, because it is what makes the rest checkable: typecheck:dev went from 320 errors to zero and became a gated CI job (#540) — it is the only gate that compiles the library from a caller's position, and it immediately turned up five exported declarations no caller could use. Alongside it: the coverage gate stopped being two implementations of one parse, with per-module floors a rollup of per-file percentages cannot express (#541, #1016); the bundled examples now run in CI (#559); the three quarantined multi-node suites run nightly with the skip flag off; and a repeat-run flake harness (bun run test:stress) landed with a Diagnosing flakes page to go with it.


Install: bun add actor-ts · npm install actor-ts

Published with npm provenance via Trusted Publishing (OIDC). Full detail in CHANGELOG.md; what is planned next is in ROADMAP.md.

v0.16.0

Choose a tag to compare

@pathosDev pathosDev released this 15 Aug 13:17
d0f15d0

The entry points, logging and old defects release, and the largest window the project has had: 181 commits since v0.15.0. Three threads run through it. Entry points — the root export stops being a barrel over the entire framework and becomes core-only, with eighteen subsystem subpaths beside it. Logging — the logger stops writing to exactly one place and grows a sink architecture, with rotating files and ten platform integrations, none of which pull in an SDK. Old defects — the 50 oldest open bug and security issues were worked as one unit, 42 of them resolved, including most of the 2026-08-01 security catalogue.

Twenty-six entries carry a BREAKING marker, each with a migration note. Two of them decide how you should deploy this, and they are first below.

⚠️ Breaking changes (pre-1.0)

  • A rolling upgrade across this release does not converge — upgrade the cluster in one step (#112). A gossip frame is a snapshot of the member map, and a member's version only moves when its status does, so a frame captured off the wire stayed valid indefinitely. Against a converged receiver that was harmless. Against an entry the receiver had deleted it was not: the failure detector's down path deletes outright so a healed partition can re-discover the peer, an expired tombstone is pruned for the same reason, and the branch that files a first sighting had no lower version bound at all. Replaying a downed member's own pre-down record therefore brought it back at its old version, up, carrying its roles — and roles are what shard placement, singleton hosting and downing quorums are computed from.

    Every frame now carries a sequence its author stamps, seeded from that node's wall clock at startup so a restart out-numbers its own previous incarnation, and a receiver drops any frame that does not out-number the highest it has accepted from that connection peer. There is no new knob: the comparison is between a peer and itself, so it needs no clock-skew budget.

    Migration. GossipMessage gains a required sequence, and a frame without it is refused at the decode boundary. Nothing outside the framework composes gossip frames, so application code is unaffected — but an upgraded peer refuses an old node's frames and an old node ignores the new one. Upgrade every node in one step, or accept that membership does not converge while both versions are running.

  • The root 'actor-ts' export is core-only; subsystems moved to subpath exports (#414). The root barrel re-exported every subsystem, which dragged the whole framework through one entry point — the testkit shipped in the production entry (#685), and import { ActorSystem } paid for whatever any subsystem pulled in eagerly (#1005). Core — actors, supervision, scheduler/dispatcher, EventStream, system messages, config, mailboxes, patterns/Router, typed behaviors, the util value types and the base loggers — stays at 'actor-ts'; everything else lives at its own entry.

    Migration. Import moved symbols from their subsystem entry:

    import { PersistentActor } from 'actor-ts/persistence';
    import { Cluster } from 'actor-ts/cluster';
    import { FileSink } from 'actor-ts/logging';

    Aliased root names keep working as spelled aliases: import { Subscribe as ReceptionistSubscribe } from 'actor-ts/discovery', import { Transition as FsmTransition } from 'actor-ts/fsm'.

  • The rest, in one line each — every one carries its own migration note in CHANGELOG.md: HttpRequest.path is the bare pathname on every backend (#605); every HTTP backend caps a request body at the same 1 MiB (#613); Lease.release() reports a failure instead of swallowing it (#598); the ClusterClient envelope no longer carries a sender field; serializeCookie is now safe by omission and validates Path and Domain (#626); the CSRF cookie defaults to the __Host- prefix (#626); CSRF origin checks compare whole origins (#604); DEFAULT_MIME_TYPES has a null prototype (#608); Express WebSocket handshakes now run the app's middleware (#623); an HMAC integrity tag can no longer be stripped to skip verification; a unary gRPC call is bounded by the configured deadline; every HttpClient call carries a deadline and a response-size cap (#602, #625); HttpClient has a redirect policy of its own, with the hop budget down from 20 to 5; getFromDirectory enforces symlinks: 'within-root'; DevTools.mount() demands an acknowledgement; @hono/node-ws must be 1.2.0 or newer for websocket() routes (#586); and MemcachedClientLike is typed in Uint8Array rather than Buffer (#1006).

🚀 New features

  • Logging grew a sink architecture (#1150). The logger wrote to exactly one place; it now fans one record out to as many destinations as you configure, each with its own minimum level, bounded delivery and a flush on shutdown.

    const consoleSink = new ConsoleSink({ minLevel: LogLevel.Info });
    const auditSink = new ConsoleSink({ minLevel: LogLevel.Error, format: 'json' });
    const systemOptions = ActorSystemOptions.create().withLogSinks([consoleSink, auditSink]);
    const system = ActorSystem.create('my-app', systemOptions);

    Nothing about the existing surface changed: Logger, ConsoleLogger, JsonLogger and NoopLogger are untouched, this.log behaves as before, and a system whose config nobody edited logs exactly what it logged yesterday. The pieces: MultiSinkLogger and the LogSink contract (#1151), BatchingSink with a bounded queue, batching, retry with jittered backoff and drop accounting (#1152), FileSink with rotation and retention (#1153), and ten platform sinks (#1154#1161) — starting with OtlpHttpSink, which reaches Grafana Loki 3+, Parseable, SigNoz, Datadog, Axiom, Honeycomb, New Relic and every OpenTelemetry Collector through one endpoint format. Every integration is dependency-free; the two that could have pulled an SDK, OpenTelemetry and Sentry, take the opposite route and say why on their own pages.

  • Per-subsystem subpath exports (#414, #1001). The exports map grew one entry per subsystem barrel, so the subpaths the documentation already used — actor-ts/http, /coordination, /serialization, /discovery — resolve now, and the smoke suite loads every declared entry on Bun, Node and Deno (#1003).

  • The default Fastify backend loads lazily (#1005). import { ActorSystem } from 'actor-ts' no longer parses Fastify and its ~20 transitive packages; the default backend resolves on the first bind, exactly like the express and hono arms always did.

  • HttpClient calls are bounded by time and by bytes (#602, #625), and carry a redirect policy of their own rather than the platform's.

  • The cluster warns at startup when remote.tls.enabled is not honoured (#591), rather than running plaintext in silence.

🔒 Security

Most of the 2026-08-01 security catalogue (#575#626) closed in this window. The larger ones: cluster frame decoding is now linear in the bytes received rather than quadratic in the chunk count (#588) — a 16 MiB frame delivered in ~1400-byte writes was roughly 100 GB of memory copying on a path that runs before the hello gate; an idle inbound cluster socket is bounded in both directions (#588); the pod's mounted ServiceAccount token is never paired with an untrusted API server; the Kubernetes API seed provider percent-encodes the path; and the Idempotency-Key header is validated before it reaches a cache key (#607 narrowed, the eviction policy in #1080 is the remainder).

📦 Packaging

The packaging surface was brought in line with what actually ships.

  • No more dangling source maps (#1007). declarationMap and sourceMap were on while files publishes only dist/, so all 1262 maps pointed at a ../src/*.ts the tarball never carried, and none held sourcesContent. A dangling map is worse than an absent one, since a missing map degrades cleanly to the .d.ts while a dangling one sends the editor after a file that never arrives. Published, the package goes from 8.07 MB over 2360 files at v0.15.0 to 6.07 MB over 1268 files — and that is with the whole logging-sink subsystem added in the same window.

  • NodeNext module resolution (#1008). The build said moduleResolution: "Bundler", but no bundler runs — the consumer is Node ≥ 24 or Deno on the real ESM resolver, and Bundler relaxes exactly the rules that resolver enforces. The mandatory .js suffix already satisfied NodeNext, so the switch changes no emitted byte; what it buys is that the next forgotten suffix is a compile error here instead of an ERR_MODULE_NOT_FOUND in your process, and that .d.ts resolution becomes exports-map-aware now that the package has eighteen subpaths.

  • @types/node is no longer a silent requirement (#1006). The declarations used Node-only types in public signatures while @types/node was a devDependency, so type-checking with skipLibCheck: false produced errors out of node_modules/actor-ts/ that were not yours to fix. NodeJS.Signals is replaced by the new ProcessSignal (a member-for-member mirror, so every call site is unchanged), MemcachedClientLike speaks Uint8Array, and @types/node is declared as an optional peer dependency for the one remaining surface — ExpressBackend, which constructs a ServerResponse at run time.

🐛 Fixed

42 of the 50 oldest open defects. A sample: inbound TCP bytes are buffered in a doubling slab rather than a fresh array per chunk (#610); an accepted socket is put on the handshake clock (#588); one rejected discovery rung no longer takes the whole ladder down; LeaseMajority tracks the acquire an abandoned reset leaves behind (#600); content-types resolve against own keys only (#608); Origin merges into a handler's Vary whatever its case (#603); and a rejected WebSocket upgrade reaches the client on Bun (#623).


Full detail, with the reasoning and every migration note, is ...

Read more

v0.15.0

Choose a tag to compare

@pathosDev pathosDev released this 12 Aug 09:04
1fa62c7

The mailboxes and channels release, and the shortest window the project has had: 58 commits and twelve issues in the day since v0.14.0. It is a minor rather than the patch it was planned as, because pre-1.0 puts breaking changes in a minor and this window carries one — the default mailbox. Two threads run through it. Mailboxes — the default is unbounded again, bounding became a deliberate act that names its own loss, and the telemetry that watches what replaced it exists for the first time. ChannelsEventStream finally accepts the kind-discriminated message style the rest of the framework argues for, and stops letting one bad subscription take the bus down with it. Underneath, constants got a placement rule, and the rule found a dead export, five duplicated values and a second path-traversal denylist.

⚠️ Breaking changes (pre-1.0)

  • The default mailbox is unbounded again (#1148). Every actor spawned without an explicit mailboxCapacity now gets the plain, unbounded Mailbox. Since #310 it got a BoundedMailbox with capacity = 10_000 and overflow = 'drop-head', which silently discarded the oldest queued message on overflow — no dead letter, no exception, only a counter.

    #310's trade was worst-case message loss for a guaranteed memory ceiling. The ceiling turned out not to exist: the system-message queue was never bounded (#794), so the framework was paying the loss without collecting the guarantee. And the loss was never confined to the telemetry-shaped workloads drop-head suits, because a mailbox cannot tell a stale sample from a control message — the tracker has one entry per victim: a death-watch Terminated evicted and the watcher blinded (#729), ReliableDelivery sends discarded with their confirm never settling (#732), DistributedData updateAsync promises stranded unsettled (#1078), and three WebSocket-hub defects where the evicted envelope was a spawn command, a close() or a disconnect signal (#717, #985, #986). None of those are reachable under the new default.

    Migration. Nothing to do if you want the unbounded shape — it is the default. To keep a bound, say so at the spawn site:

    // before — the bound was implicit, and so was the drop policy
    system.spawn(Worker, 'worker');
    
    // after — bounding is the deliberate act, and it names its own loss
    const workerOptions = ActorOptions.create<WorkerMessage>()
      .withMailboxCapacity(10_000)
      .withMailboxOverflow('drop-head');
    system.spawn(Worker, 'worker', workerOptions);

    Unbounded does not mean unobserved. ActorCell warns when a mailbox crosses 10 000 queued messages and again at every doubling, and metrics gained the actor_mailbox_size gauge that the tuning docs had been documenting for a gauge that did not exist. actor_mailbox_dropped_total still exists and now only counts drops someone asked for.

🚀 New features

  • EventStream channels can be kind-discriminated types, not just classes (#1143). subscribe/unsubscribe took a class constructor and matched with instanceof, which locked the one API the project offers for loosely coupled fan-out to exactly the message style the project argues against — and since publish(event: object) has always accepted a plain object, you could publish something nobody was able to subscribe to. A channel is now named three ways: by a class, exactly as before; by an EventKey, a new export mirroring ServiceKey/ShardKey; or by the bare kind string, which costs the type unless you supply the type argument.

    export type UserLoggedInEvent = { readonly kind: 'user-logged-in'; readonly userId: string };
    export const UserLoggedInEvent = EventKey.of<UserLoggedInEvent>('user-logged-in');
    
    eventStream.subscribe(self, UserLoggedInEvent, (event) => event.userId !== 'system');
    eventStream.publish({ kind: 'user-logged-in', userId: 'user-42' });

    A key and its string are the same channel — subscribing both ways dedups, and either form unsubscribes the other. A class and a kind are two channels even when the class's instances carry that kind. Prefix and wildcard families ('billing.*') are deliberately not included; they are purely additive later.

  • A collision predicate on every random-id helper (#1141). randomString, randomHex, randomId and randomUuid take an optional exists callback and draw again while it answers true, so the loop every caller wrote by hand collapses into randomUuid((id) => state.users.has(id)). The polarity is the design: the callback is that while condition, which keeps the ! off Map- and Set-backed call sites. Bounded at 1 000 draws, then throws an Error naming the helper and the count. ExistsPredicate is exported from the root barrel.

  • Every mailbox reports its drops, not just the one the framework built (#1149). actor_mailbox_dropped_total was fed by an onDrop the cell passed into the BoundedMailbox it constructed, so a mailbox supplied through withMailbox was invisible to it. The cell now registers its observer after choosing the mailbox, on anything implementing the new DropReportingMailbox contract — a structural probe rather than an instanceof check. Registration is additive: a BoundedMailboxOptions.onDrop of your own keeps firing alongside the stock counter. New exports: DropReportingMailbox and MailboxDropReason.

  • Mailbox and Envelope are exported from the package root (#661) — the escape hatch the docs described was previously impossible to import. mailboxOverflow / withMailboxOverflow is a real ActorOptions field too (default drop-head; setting it without a capacity is rejected rather than silently ignored).

  • Three of the framework's own identifier draws go through the exists predicate (#1146). The framework mints twelve identifiers; only three have a registry in scope and a failure worth preventing. ActorCell's anonymous child names now draw against this._children, ORSet.add against the element's live tags and its tombstones, and ClusterClient.ask's id against the pending map. The other nine are recorded on the issue with the reason rather than left to be re-derived.

🐛 Fixed

  • One faulty EventStream subscription no longer breaks the bus for everyone else (#1010). publish guarded the subscription's predicate but not its channel: the instanceof test sat one line above the try and subscriber.tell ran unguarded below it, so of the three things that can throw per subscription exactly one was covered. A single bad entry raised a TypeError into whoever called publish, and because the throw escaped the loop, every subscription registered after it silently stopped receiving anything — in an order decided by subscription order, which no caller controls. Since publish runs on every actor start, every actor stop and every dead-lettered tell, that turned ref.tell(…) — an API that does not throw by contract — into one that did.

    subscribe now rejects a channel that is not a usable instanceof right-hand side, throwing on the line that wrote the subscription. publish runs the match test, the predicate and subscriber.tell under a guard and carries on to the next subscriber. Behaviour change: a subscriber.tell that throws is now logged and swallowed rather than propagated out of publish.

  • A dead constant and its live duplicate (#1142). DEFAULT_SNAPSHOT_CACHE_TTL_MS had zero importers while the consumer its own docblock named declared the same five minutes locally as DEFAULT_TTL_MS.

  • reEncryptionSweep rebuilt the ATS1 magic prefix (#1142) instead of importing the ATS1_MAGIC that BodyCodec already exports — a second copy of a format definition.

  • The heartbeat interval existed twice (#1142). defaultFailureDetectorOptions and defaultPhiAccrualOptions each carried heartbeatIntervalMs: 500, so swapping detectors could silently change how often a node talks to its peers — and only the first was pinned to reference.conf by a test.

  • Two independently-introduced redraw caps (#1142) and unnamed literals mirroring reference.conf (#1142) — the dispatcher throughput was a bare 16 in three places, and ShardCoordinator resolved its rebalance interval and hand-off timeout against ?? 2_000 and ?? 10_000. All now named and verified against the HOCON leaves they mirror.

🔒 Security

  • One path-traversal denylist instead of two (#1142). ActorPath and PersistenceIdValidator each declared new Set(['.', '..']) under a different name. Both guard against the same attack — a persistence id becomes a filesystem or object-storage key where .. climbs out of the configured prefix (#133), a path segment reaches actor-selection resolution — and neither imported the other, so extending one would have left the other accepting what it now rejects. Shared as PATH_TRAVERSAL_SEGMENTS, typed ReadonlySet. No behaviour change: both sites reject exactly what they did before.

🧹 Internal

  • Constants have a placement rule, and follow it (#1142). src/ held ~300 module-level SCREAMING_SNAKE constants across 130 files with no documented rule for where any of them belonged. A constant now has exactly four possible homes, checked in order, written down in AGENTS.md. Eight Constants.ts modules hold 42 values; ~20 misplaced options defaults moved next to the option they back. Every public name is unchanged — the barrels re-export from the new location, so no import breaks.

v0.14.0

Choose a tag to compare

@pathosDev pathosDev released this 11 Aug 15:30
b08bafd

The caps, codecs and lifecycle release, and by some distance the largest window the project has had: 266 commits, 106 issues, eighteen breaking changes. Three threads run through it. Caps — the cluster-wide registries, the member map, the metric label space and the quorum queue are all bounded now, and a gossiped record is held to those bounds rather than believed. Codecs — CBOR reaches rich-type parity with the JSON tree, which took four encoding changes to get right, and Avro and Protobuf ship beside it. Lifecycle — a restart stops the actor's children, a resumed actor brings its subtree back with it, and both of those had been quietly impossible before. Underneath: every severity: high finding from both audit passes is closed, including a cluster hello that trusted whatever identity a peer claimed. Pre-1.0 — this minor carries breaking changes, and one of them rules out a rolling upgrade; see below.

🚀 New features

  • Scatter/gather router (#153) — Router.scatterGatherFirstCompleted(size, routee, options?) asks every routee at once and answers with the first reply: Akka's ScatterGatherFirstCompletedPool, the hedged-request pattern for tail latency. The fan-out is not awaited in the handler, so concurrent scatters overlap instead of serialising the router's mailbox; every failure rejects with an AggregateError carrying one error per routee, and the message distinguishes "nobody replied in time" from "everyone failed". Stopping or restarting the router fails its open scatters immediately rather than running out the clock. withTimeoutMs defaults to 4 500 ms — deliberately under ask's 5 000, because the router can only name the failing routees after its own deadline has passed (#1088).
  • TCP listener actor (#158) — TcpServerActor binds a port and serves every connection it accepts, closing the one half of the raw-TCP API that was genuinely missing. Framing is applied per connection with its own re-assembly buffer, TLS and mTLS go through the same cross-runtime layer the cluster transport uses, and maxConnections refuses at the door. Connections are addressed by an opaque connectionId rather than getting an actor each, because an actor's restart semantics cannot resurrect a peer's TCP connection.
  • CborSerializer carries the same rich types as the JSON tree (#1036) — Map, Set, BidirectionalMap, RegExp, URL, Error, the typed arrays, DataView and ArrayBuffer all round-trip, most through their registered IANA tags. Until now a store configured with a CBOR serializer silently degraded them to {}.
  • Avro and Protobuf serializers (#73) — plus serializerCodec(serializer), which adapts any byte-native serializer to the codec seam, so one implementation serves both.
  • Live and cursor-paginated persistence-id queries (#156) — allPersistenceIds() streams entities as they appear; currentPersistenceIdsPaginated(cursor, limit) walks them in pages. Paging is pushed into the backend wherever a sorted key over ids exists (ORDER BY … LIMIT on SQLite and the SQL dialects, a clustering range on Cassandra); MongoDB and DynamoDB have no such index and fall back to an in-process page.
  • BidirectionalMap<K, V> and BidirectionalMultiMap<L, R> (#1035, #1037) — a Map that answers in both directions, and its many-to-many sibling. Both round-trip through every store as tagged JSON-tree values, so they can be held in an actor's state directly: no snapshot adapter, no serializer registration, nothing at the boundary. The receptionist, the pub-sub mediator and the broker base now index their subscribers through the multi-map instead of each maintaining the same relation by hand.
  • DistributedPubSub anycast (#155) — Publish takes a third argument, delivery, and 'one-subscriber' hands the message to exactly one subscriber cluster-wide: the work-queue shape a broadcast bus cannot do without the workers coordinating among themselves. Selection rotates a per-topic cursor over local subscribers and the remote nodes claiming the topic, so ten tasks over three workers land 4/3/3 rather than "probably roughly even".
  • JetStream Key-Value and Object-Store actors (#74), gRPC client-streaming as its own call mode (#5), and gRPC health checking (#4) — GrpcServerActor can now host grpc.health.v1.Health from the HealthCheckRegistry.
  • Cluster subscriptions get a CurrentClusterState snapshot replay and a ReachabilityChanged event (#161), and stable-observation bootstrap (#148) decides whether, and when, a node may elect itself rather than racing its peers into two rival clusters.
  • Death watch with a custom termination message (#159) — watchWith(ref, message) delivers a message you choose instead of Terminated, so a watcher that already speaks one protocol does not need a second arm for the framework's.
  • Typed Behaviors.intercept / monitor / logMessages (#152), Router.smallestMailbox (#154) and smallest-mailbox for the cluster router (#69).
  • LogContext.runFresh(fn) and runEach(entries, fn) (#129) — the two seams where deferred work either belongs to nobody or belongs to somebody different per item. Without them a batch drained in one turn logged every item under whichever tenant happened to start the turn.
  • randomUuid(), randomString, randomHex, randomId, safeStringify, lazyImportModule on the public surface (#1034, #1109) — all six already existed and the framework runs on them; nothing but the barrel kept them from consumers.
  • acquireLock(cache, key, ttlMs) (#141) — mutual exclusion over any Cache, and the setIfAbsent atomicity contract that makes it sound is now written down and proven across all three backends.

⚠️ Breaking changes (pre-1.0)

  • The cluster wire protocol's discriminator is kind (#494). The framework had three spellings for one concept: t on the cluster wire and the internal coordinator/singleton events, $t on the sharding protocol, kind everywhere else. A rolling upgrade is not possible — a v0.13.0 node and a v0.14.0 node cannot talk. Stop the cluster, upgrade every node, start it again.

  • CborSerializer encodes several values differently (#1036). All of these previously produced something wrong rather than something different, so the migration is usually "delete the workaround": Map, Set, RegExp, Error and the typed arrays no longer encode as {}; undefined is CBOR simple value 23 rather than being dropped; -0 is written as a float instead of collapsing to 0; wrapper objects are unwrapped, and Promise / WeakMap / WeakSet are refused rather than silently encoded as nothing.

  • A restart stops the actor's children (#634). Children used to be inherited by the new incarnation, which made an ordinary pattern impossible: postRestart re-runs preStart, so an actor that spawned a named child there hit Child name … is not unique on its first restart and never recovered. Migration: an actor whose children should outlive a restart overrides Actor.stopChildrenOnRestart() to return false and adopts the survivor in preStartthis.child = this.context.child('name').toNullable() ?? this.context.spawn(Child, 'name'). An instance field cannot carry it across: preStart runs on a fresh instance, so this.child ??= … is always unset and re-spawns into the name the surviving child still holds.

  • A sharded entity's child name escapes its id injectively (#568). entityName() folded every character outside [A-Za-z0-9_-] to _, which is many-to-one — a.b@x.com and a-b@x.com collided, and the collision killed the whole Shard actor along with every unrelated entity in it, including other tenants'. Ordinary ids are unchanged; anything outside [A-Za-z0-9_-.@:+] is now escaped as ~ plus four hex digits.

  • persistenceId is validated before it becomes a storage key (#133). Empty ids, ids over 255 characters, / and \ separators, whole-id . / .. and control characters are rejected in preStart — the rules actor names have had all along, mirrored onto the one identifier in the persistence layer that had no validator.

  • Recovery over a journal compacted without a covering snapshot now fails instead of inventing a state (#122). It used to fold the surviving tail onto initialState() and hand that to onCommand as the current state. Migration: compact only past a snapshot — deleteHistory(seq) keeps the snapshot at seq for exactly this reason — or take one before compacting.

  • redirect() rejects off-origin targets (#125). Forwarding a ?next= parameter into it was a textbook open redirect. Same-origin targets only; use the new redirectExternal(url, status?) where the off-origin hop is deliberate.

  • The HKDF info parameter is required for client-side encryption (#108). info is HKDF's context binding, so the framework-wide constant it defaulted to meant any two deployments holding the same master key derived byte-for-byte the same subkey — a staging environment restored from a production dump could read production snapshots.

  • The two cluster-wide subscriber registries are bounded and watched (#137, #139), and DistributedPubSub's Subscribe takes an optional replyTo (#139). A refused Subscribe is now answered rather than discarded; existing calls compile unchanged and keep following the sender.

  • ClusterOptions.firstSightMaxVersionSkewMs is now maxVersionSkewMs (#114) — same default, same unit, no HOCON key to change. The old name stopped being true once the cap applied to every merge rather than to a first sighting.

  • Smaller ones, each with a note in the CHANGELOG: Publish's third constructor slot is delivery, not the no-op sendOneMessageToEachGroupdo not rewrite true to 'one-subscriber', the old flag broadcast and the new value does not; PersistenceQuery requires the two new query meth...

Read more

v0.13.0

Choose a tag to compare

@pathosDev pathosDev released this 05 Aug 03:50
80fa80c

The names and lifecycle release. Two threads that turned out to be the same thread: what an actor is built from, and when it goes away. Props is gone — spawning takes the actor class, and per-actor configuration became ActorOptions, an ordinary options family like every other one in the framework. Sharded entities now passivate by default, empty shards stop with them, and the generated names the framework hands out are no longer guessable. Underneath, a cluster-transport bug that could partition two healthy nodes permanently, and the discovery that the documented mTLS recipe was never actually authenticating anyone. Pre-1.0 — this minor carries breaking changes; see below.

🚀 New features

  • ActorOptions (#547) — withSupervisorStrategy, withDispatcher, withMailboxCapacity, withMailbox, withInternal, withEntity, withDisplayName, plus an ActorOptionsValidator that rejects a non-positive mailboxCapacity at the spawn call rather than from inside the mailbox constructor. Accepted as a builder or as a plain object, like every other options family.
  • Actor.displayName() — a readable name for an actor in logs and DevTools (#891). A path is an address, not a name: under sharding the log source grows to ~120 characters of machine identifier, and the business identity it stands for had to be repeated by hand in every message the entity logged. Override displayName() and the actor says it once — the name joins the line as its own segment (... - User(test-user-590) - recovery complete) and labels the row in the DevTools actor tree, which is worth the most for Behaviors actors whose class column reads TypedActor on every row. Also settable at the spawn site with ActorOptions.withDisplayName(...) and at runtime with context.setDisplayName(...). Defaults to the path, so existing log output is unchanged, and it stays a label: metrics, tracing, dead letters and every cluster-wire identifier keep using the path.
  • Empty shards passivate (#892). A shard actor used to outlive its entities indefinitely — since entity ids spread over the hash space, a long-running node accumulated one idle, empty shard actor per numShards. A shard that has stood empty for shardPassivationIdleMs is now stopped too. The region keeps ownership, so the shard stays routable and the next message re-creates it with no coordinator round trip.
  • shardPassivationIdleMs / withShardPassivationIdleMs() / actor-ts.sharding.shard-passivation-idle (#892). Unset, it follows passivationIdleMs; 0 keeps empty shards resident while entities still passivate.
  • ShardInfo.resident (#901) — ClusterSharding.shards() now reports whether each shard actor was materialised when its region answered. entityCount: 0 cannot say that on its own: a running-but-empty shard and one that passivated report the same count.

⚠️ Breaking changes (pre-1.0)

  • Props is gone from the public API (#547). Spawning takes the actor class or a factory directly:

    // before
    system.spawn(Props.create(() => new Greeter()), 'greeter');
    system.spawn(Props.create(() => new Worker(db)).withMailboxCapacity(500), 'w');
    
    // after
    system.spawn(Greeter, 'greeter');                      // zero-arg class
    const workerOptions = ActorOptions.create<WorkerMessage>().withMailboxCapacity(500);
    system.spawn(() => new Worker(db), 'w', workerOptions);

    Props bundled two unrelated things — what to construct and how to run it — and 75 % of its ~970 call sites used only the first. Migration: drop Props.create( and its closing ); move each .withX(…) into a third ActorOptions argument; asInternal() becomes withInternal(). Renamed carriers: entityPropsentityActor, singleton propsactor, singletonPropssingletonActor, childPropschild, routeePropsroutee, behaviorForactorFor; BackoffSupervisor.props.factory, ClusterRouter.props.factory, typedPropstypedActor. Two behavioural notes: ActorOptions mutates in place where Props was copy-on-write (settings are snapshotted at spawn), and a class whose constructor takes arguments is now rejected at the spawn call instead of being constructed with undefined dependencies.

  • Idle entities passivate by default, after 5 minutes (#892). passivation-idle shipped as 0ms, so nothing ever passivated until an operator went looking for the key, and entity sets only grew. Migration: an entity that keeps state in memory and does not rebuild it in preStart now loses that state after five minutes idle — persistent entities recover, plain ones do not. Set passivation-idle = 0ms to restore the old behaviour. ShardedDaemonProcess opts out on its own.

  • The cluster TLS listener requests a client certificate (#565). See Security — a cluster already passing ca starts demanding peer certificates, and mutual TLS on Deno is now refused rather than silently skipped.

  • Anonymous actors are named $anonymous-<n>-<random>, not $1 / $2 (#895). The old per-parent counter was both opaque and guessable — /user/$1 is the first anonymous actor of every run, and a path is an address. Migration: code that hard-codes an anonymous path or parses $<n> out of a name must spawn with a name of its own.

  • Unnamed reliable-delivery controllers are consumer-<n>-<random> / producer-<n>-<random> (#897). The fallback came from a module-global counter, so /system/delivery/consumer-1 was the first one of every run, and two ActorSystems in one process drew from the same sequence.

  • Actor names starting with $ are reserved for the framework (#900). spawn and spawnTyped now reject them — until now anyone could claim the prefix spawnAnonymous generates. A $ anywhere other than the first character is unaffected.

🔒 Security

  • The cluster TLS listener never requested a client certificate (#565, severity: critical). requestCert hard-defaulted to false in both listener adapters, and requestClientCert was never set to true anywhere in the repo. On a server rejectUnauthorized does nothing unless requestCert is on — so the mTLS recipe the Cluster security page documents produced server-authenticated TLS only. Since the hello handshake carries no credential of its own, that left the peer certificate — the cluster's only admission control — unrequested: anything that could reach the remoting port completed the handshake presenting nothing and then claimed whatever node identity it liked. It is also the mitigation several other findings lean on, so until now those notes promised more than the transport delivered. requestClientCert now defaults to ca !== undefined, and two incoherent configurations fail closed at bind time: requestClientCert: true with no ca, and mutual TLS on Deno, where Deno.listenTls cannot request a client certificate and the dialer sends none.
  • Quorum correlation ids in DistributedData are no longer guessable (#896). nextPendingId() returned p<Date.now()>-<counter>. That value travels on the wire and the peer echoes it back on its acknowledgment, so a guessable id is one whose acknowledgment can be forged — satisfying a quorum write or read no peer actually confirmed. Now sixteen random hex characters.
  • A ClusterClient's own wire identity no longer comes from Math.random() (#910). The synthetic port a client names itself by goes into the NodeAddress it announces and keys the cluster's byPeer map, so it is an address — and Math.random() is not a CSPRNG. The comment above it claimed hrtime-derived randomness, which the code never did. Now drawn with crypto.getRandomValues across the whole ephemeral range; the old 15 000-slot window also made accidental collisions likely at a few dozen clients per process, which was a correctness problem on its own.
  • Filesystem object-storage temp paths no longer come from Math.random() (#898). The atomic-write temp file was named with the clock and Math.random(), so a local process sharing the directory could predict the path and pre-create it or plant a symlink there.

🐛 Fixed

  • Two nodes that dial each other at the same moment no longer stay split forever (#697). openOutbound registers a connection in byPeer before the handshake, and the hello-hijack guard compared identity alone — so in a crossing dial each node held an un-acked outbound under the other's key and rejected the other's perfectly legitimate hello. Neither dial then received its hello-ack, and onClose released the slot only if the handshake had completed: no re-dial, and every frame for that peer accumulating silently in the handshake buffer. The pair was partitioned for the lifetime of the process. Cleanup is now keyed on the dialled address, a 5 s handshake deadline reclaims a dial that connects but never acks, the buffer is capped, and a crossing dial is settled by address order so exactly one survives. An established peer connection is still never displaced.
  • rememberEntities no longer forgets every entity when a shard rebalances (#632). The departing region announced an EntityStopped for every entity of the shard, which emptied the coordinator's registry — so when the shard was reallocated there was nothing left to ship to the new owner. A rebalance is the ordinary path, so this was rememberEntities failing at the one thing it exists for; it survived because the only coverage was a cold restart, which reloads from the journal and never exercises a live handoff.
  • Filesystem object storage stopped recognising its own temp files (#909). The Math.random() removal above changed the temp-file name without updating the pattern list() uses to skip them, so a crashed writer's partial body was reported as an ordinary object. The test had staged the old shape as a literal, which ...
Read more

v0.12.2

Choose a tag to compare

@pathosDev pathosDev released this 04 Aug 09:56
811d7f1

The payload fidelity release. Every journal, snapshot store and durable-state store wrote payloads with bare JSON.stringify — so a persisted Set or Map recovered as {}, a Date came back a string, a Uint8Array an index-keyed object, and a bigint threw outright. Because the write path folds the original object into state, none of it surfaced until the next recovery. Payloads now go through the tagged JSON tree format JsonSerializer already used, on every backend, and that format grew to cover essentially everything JavaScript can hold.

Numbered a patch, but read the breaking change below before upgrading: this window carries one.

🚀 New features

  • Full type fidelity for stored payloads and JsonSerializer (#889) — NaN / Infinity / -Infinity / -0, undefined in value positions (array slots, Set members, Map entries — object properties still drop, matching JSON.stringify), RegExp (source + flags), URL, Error (name + message + cause, including subclass constructors and AggregateError.errors) and every typed array / DataView / ArrayBuffer now round-trip through every store and the JSON serializer. Error stacks are deliberately not stored — they would leak filesystem paths into long-lived rows. Number/String/Boolean wrapper objects unwrap like JSON.stringify does; Promise, WeakMap and WeakSet throw a SerializationError at persist time instead of being silently stored as {}.
  • Per-store serializer option (#888, the persistence half of #450) — every journal / snapshot store / durable-state store options builder, and every Register<X>Plugins bundle, takes withSerializer(serializer) to route a custom Serializer into stored rows through a self-describing __serialized__ framing. Default-format rows and framed rows coexist in one stream, so you can switch a running system's serializer without a migration; reading a framed row without — or with a mismatching — serializer fails with an actionable SerializationError rather than garbage. Registry auto-binding and the cluster wire remain tracked in #450.

⚠️ Breaking changes (pre-1.0)

  • Persistence stores no longer silently corrupt rich payload types (#888) — the fix changes what gets written. Payloads are stored as the tagged JSON tree (__date__, __bytes__, __map__, __set__, __bigint__, plus a new __literal__ escape so user data shaped like a tag round-trips as data) on every backend.

    Migration: none for readers. Rows written by older versions decode unchanged, so an existing journal keeps replaying. What changes is the write side: rows written from this version on carry tag objects wherever plain JSON would have corrupted the value, so older framework versions — and non-actor-ts consumers reading your tables as plain JSON — see the tag shape instead of a bare value. Plan a rolling downgrade accordingly, and check any external reader (a BI job, a dashboard query) that parses payload columns directly.

    JsonSerializer also now honours toJSON(), reports circular references as a SerializationError naming the key path instead of overflowing the stack, and only interprets a tag when it is an object's sole own key.

🔧 Changed

  • The in-memory journal, snapshot store and durable-state store round-trip payloads through the same codec as the real backends (#888) — dev/prod parity. An event that cannot be stored now fails in your test suite instead of on the first production recovery, and mutating an object after persist no longer aliases into the store. Like the real stores, they still return and publish the caller's original objects, so nothing observable changes for payloads that were always storable.

📚 Documentation

  • "What events and state may contain" answers the payload question with a table instead of a caveat — a new section on the Persistent actor page lists every payload category against its actual round-trip behavior: stored as plain JSON, round-tripped as a real instance via a tag, dropped like JSON.stringify, or thrown at persist time. It holds for every store on every backend, including the in-memory ones, and it states the reader guarantee — rows written by earlier versions keep decoding unchanged, tags appear only in newly written rows where plain JSON would have corrupted the value. The JSON serializer page gained the matching fidelity list (the tag set, toJSON(), the __literal__ escape, circular references reported by key path), and the envelope-format migration page now describes _e as rich-type-capable rather than JSON-safe-only.
  • Per-store serializers are documented end to end — a new "Using a custom serializer for persistence" section covers the __serialized__ framing, why old and framed rows coexist in one stream, the SerializationError you get when the serializer's id no longer matches, how Register<X>Plugins fans one serializer out to a backend's stores (a leaf's own wins), and the deliberate exception: the in-memory stores ignore the option and always use the stricter default codec, so a test cannot pass on something production would reject.
  • Both, as always, EN + DE 1:1 — 8 pages per language.

v0.12.1

Choose a tag to compare

@pathosDev pathosDev released this 03 Aug 00:02
67a3cec

The config honesty release. Every key reference.conf ships is now actually read by something — the sharding, cluster, remote, http, system, worker-cluster and coordinated-shutdown blocks were documented, shipped, and inert; explicit options > HOCON > built-in defaults now holds across all of them. A CI guard fails the build on the next key nothing reads, and a new docs page publishes the complete reference.conf verbatim, pinned to the source by a test. Alongside that, actors can reach two things they previously had to be handed: their own Cluster and their own entityId.

Numbered a patch, but read the breaking change below before upgrading: this window carries one, plus several HOCON key renames.

🚀 New features

  • An actor can reach its own Cluster (#833) — this.cluster (unwrapped, throwing when the system never joined one), this.context.cluster and system.cluster (both Option<Cluster>). The Cluster was the one runtime object that had to be threaded in by hand, and a framework-constructed actor — a sharded entity, a singleton — has no call site to thread it through at all. cluster.sharding / cluster.singleton come along, so an actor can start a region or a singleton from the inside. All three read through to the system on every access, so an actor that outlived the join still sees the cluster, and a system that rejoined after leave() resolves to the new instance rather than the dead one. Registration is a new ClusterExtension that Cluster.join is the sole writer of; core keeps its runtime independence from the cluster layer.
  • A sharded entity can read its own entityId (#832) — the routing id used to stop at the Shard that spawned it, recoverable only by slicing the entity- prefix off the actor path. That was boilerplate at every call site and lossy: actor names have a restricted alphabet, so user:42 and user/42 both read back as user_42 (#568). Props.withEntity({ entityId, typeName, shardId }) is the same door ClusterSharding uses, left public so an entity can be unit-tested without a cluster around it.
  • actor-ts.sharding.max-entities — the per-node entity cap is configurable (#835) — maxEntities LRU-passivates the coldest entity at capacity, and it was the one passivation trigger with no HOCON form, leaving the time bound tunable per environment and the memory bound code-only. An entity count is exactly the value that differs between a laptop and a 64 GB production node. Reference value is 0 (no cap), so nothing changes for anyone who does not set it.

⚠️ Breaking changes (pre-1.0)

  • ReplicatedEventSourcedActor no longer takes a Cluster, and replicaId has a default (#833) — both existed only because the actor could not reach its own cluster.

    // before
    class Counter extends ReplicatedEventSourcedActor<Command, Event, State> {
      readonly persistenceId = 'counter-1';
      readonly replicaId: string;
      constructor(cluster: Cluster) { super(cluster); this.replicaId = cluster.selfAddress.toString(); }
    }
    new Counter(cluster);
    
    // after
    class Counter extends ReplicatedEventSourcedActor<Command, Event, State> {
      readonly persistenceId = 'counter-1';
    }
    new Counter();

    Migration: drop the cluster constructor argument and the super(cluster) it fed — a subclass with no other dependencies can drop its constructor entirely. replicaId defaults to this.cluster.selfAddress.toString(), which is what every in-repo subclass set it to by hand. A custom replicaId becomes a getter, since as a field it now collides with the base-class accessor (TS2610): override get replicaId(): string { … }.

  • HOCON keys renamed. All were inert before this release, so no working configuration changes meaning — but a file that named them was never doing anything:

    • actor-ts.remote.max-frame-sizeremote.max-frame-bytes, and its published default moves 1M16M. Nothing read the key, so every cluster has always run at the 16 MiB code default; publishing 16M states what the framework does. If you sized your deployment against the documented 1 MiB, set max-frame-bytes = 1M explicitly — it now works.
    • actor-ts.remote.tcp.hostnameremote.tcp.host, matching ClusterOptions.host.
    • actor-ts.worker.*actor-ts.worker-cluster.*, and countworkers, in lockstep with WorkerClusterOptions.
    • actor-ts.coordinated-shutdown.exit-jvmexit-process — a JVM-ism in a TypeScript framework, and it now does something: process.exit(0) once the pipeline completes.
  • Two dead keys removed rather than wired: cluster.leader-election (the leader is always the lowest-addressed up-member; there is no second strategy) and remote.transport (a custom transport is an object passed to withTransport(…), never a string).

  • actor-ts.http.shutdown-grace-period's published default moves 5s0msunbind() has always been called with no grace period, so 0 is what every deployment has actually been running. Making the documented 5s live would have cost real time: where a backend's close() cannot settle, the window is a deadline always reached, not an upper bound that resolves early. Raise it deliberately if you want in-flight requests to finish.

  • Cluster.join without host/port no longer throws. Validation runs on the merged settings and the reference config supplies both, so it now binds 0.0.0.0:2552. That is the point of the feature, but it turns a startup error into a running node — pin the address in config if you were relying on the throw.

🐛 Fixed

  • The actor-ts.sharding.*, cluster.* and remote.* blocks are actually read (part of #653; closes #754) — the keys shipped, the docs explained them, and Cluster.join took every value from ClusterOptions alone. Env-var substitution (port = ${?ACTOR_TS_PORT}) is applied now too. failureDetector merges per threshold, not per object, so setting only downAfterMs in code keeps heartbeat-interval and unreachable-after from the file.
  • actor-ts.http.backend and http.shutdown-grace-period are actually read (part of #653) — bind() hardcoded new FastifyBackend(). useBackend(…) still wins; the config only decides what bind() picks when the builder was given nothing. An unrecognised name now fails with a ConfigError naming the key and the accepted values instead of silently falling back. The reference comment advertised fastify | bun | express — a bun backend that has never existed, and no mention of the Hono backend that does; corrected to fastify | express | hono.
  • actor-ts.system.name, worker-cluster.* and coordinated-shutdown.* are actually read (part of #653) — ActorSystem.create() now takes an optional name, falling back to actor-ts.system.name then "default"; create('billing') still wins. coordinated-shutdown.default-phase-timeout seeds the 12 canonical phases (was hardcoded to 5_000), and terminate-actor-system = false drops the built-in terminator task while leaving the phase and any user tasks intact. An unknown worker-cluster.restart-policy is now rejected by WorkerClusterOptionsValidator instead of falling through the internal match and silently meaning "never restart".
  • A guard against the next dead config key (closes #653) — tests/unit/config/NoDeadConfigKeys.test.ts asserts, for every leaf in REFERENCE_CONF, that it is reachable from ConfigKeys and referenced from somewhere under src/. Knowingly-unimplemented keys go in KNOWN_DEAD_KEYS with the issue that will remove them — one entry today (remote.tls.enabled, #591) — and the guard checks each excused key still exists, so an exception cannot outlive its key.
  • ShardedDaemonProcess no longer regex-parses its own actor name to find its daemon index, and the chat example's direct-message persistenceId is built from the real |-separated pair id rather than the sanitized one.

📚 Documentation

  • A new page publishes the complete reference.conf — every setting the framework ships, verbatim, so "what can I configure?" has one exhaustive answer instead of a curated example. The Configuration page keeps explaining what each key does and links across. The copy is pinned to the source: a test compares the page's HOCON block to REFERENCE_CONF and fails on any drift, in both languages.

v0.12.0

Choose a tag to compare

@pathosDev pathosDev released this 01 Aug 16:32
c66d8f6

The addressability release. Things that existed but could not be named now can be: a shard is a real actor with a real ActorRef, an entity has a location-transparent handle, singletons and sharded types carry typed keys declared on the actor class, and framework actors moved out of /user into grouped /system paths. Underneath that, the DevTools suite (an embeddable web UI for a running system), five more persistence backends on a new relational base layer, and a broad correctness pass over the 2026 audit. Pre-1.0 — this minor carries breaking changes; see below.

🚀 New features

Cluster addressing

  • A shard is a real actor: Region → Shard → Entity (#511) — entities are grandchildren of the region at /user/sharding-<type>/shard-<n>/entity-<id>. The Shard actor owns the entity lifecycle; routing, buffering and passivation policy stay in the region, which is what keeps maxEntities meaning "per node". Handoff is now simply "stop the shard", so HandOffComplete finally means what it says. See Breaking changes for the cost.
  • Shard introspection (#151) — ClusterSharding.shards(typeName) answers cluster-wide with a ShardInfo per placed shard (id, hosting node, region path, live entity count, locality, and a usable ref); shardRefFor(typeName, shardId) hands back one shard's ref and allocates it if it had no home. The coordinator fans GetShardRegionStats out to the registered regions and joins the answers against shardHome; a region that misses the deadline contributes 0 rather than failing the call. New StartEntity / GetShardStats shard commands.
  • ClusterSharding.entityRefFor(typeName, entityId) (#512) — a location-transparent handle to a single entity. It wraps each message in an id-addressed envelope the region routes without consulting extractEntityId, so the message type no longer has to know how it is routed. Synchronous (the shard is hash(entityId) % numShards), and a proxy region is enough to hand one out.
  • SingletonKey and ShardKey — typed, class-declared identities (#523) — declared as a static on the actor itself, tying the typeName and the message type together the way ServiceKey already did for the Receptionist. ShardKey carries the extractEntityId alongside the name; SingletonKey carries an optional role. Identity is the name alone in both cases, and options still override.
  • cluster.singleton (#523) — the facade mirroring cluster.sharding, plus ClusterSingleton.ref(key) for a singleton ref on a node that never hosts it (the counterpart to startProxy), stop(key), managerFor(key) and isStarted(key).

Persistence

  • Relational base layer (#389) — a new SQL backend is a SqlDialect + a SqlPool adapter + three thin subclasses.
  • Five more backends (#438) — MongoDB, DynamoDB, Microsoft SQL Server, libSQL / Turso and Cloudflare D1, each with journal + snapshot + durable-state. CockroachDB and YugabyteDB certified on the Postgres stores (#401).
  • SqliteDurableStateStore, SQLite persistence on Deno, LazyStore, close?() on DurableStateStore, SqlDialect.rowLimit(count), and by-tag projections that accept a full TagFilter.
  • PersistenceExtension.configure({ journal?, snapshotStore? }), plus CassandraJournalOptions.withLightweightTransactions() / .withSerialConsistency().

DevTools

  • An embeddable web UI for a running system (#445) — seven panels on one versioned tap protocol, behind a ./devtools export. Absorbs the separately-tracked live cluster visualizer (#204). --devtools-host makes the examples' bind interface configurable.

Core & observability

  • Actor lifecycle events on the EventStream (which now accepts abstract classes as channels), ActorSystem.startedAtMs, CoordinatedShutdown.removeTask(phase, name), Props.asInternal(), MetricsExtension.disable(), replayState(), TeeTracer, a bounded RecordingTracer with monotonic timings, and RetryOptions.sleep.

Tooling & CI

  • Benchmarks are gated (#506) — a new benchmarks workflow runs typecheck:bench (a benchmarks-only compile) and bench:smoke (every suite, one unwarmed iteration, ~30 s). Nothing looked at benchmarks/ before, so a src/ change that orphaned one was invisible.
  • Package-health CI — publint, arethetypeswrong and knip; plus "sideEffects": false and a memoized ActorPath.toString().
  • The committed DevTools UI bundle is CI-gated (#521) by a source-hash, not a byte diff — bundle bytes vary with the OS and Bun release that produced them, so a byte diff is not a staleness signal.

⚠️ Breaking changes (pre-1.0)

  • A shard is a real actor (#511) — anything that resolved an entity by path must insert the shard-<n> segment (hashShardId(entityId, numShards)); ActorPath.parent of an entity is now its shard. This costs throughput and the number is not small: every message to a local entity takes one extra node-local hop, measured at ~40k → ~29k ask/s on one node (−28 %, +9 µs) and ~42k → ~25k on two nodes (−41 %, +16 µs). If you route hot-path traffic through a region and relied on the old numbers, this is the change that moved them.
  • Framework actors moved from /user to grouped /system paths (#509) — the DevTools hub, shard regions and coordinators, the singleton manager, the pub-sub mediator, the receptionist, DistributedData, reliable-delivery controllers and projections all left /user, and dropped the name prefix that only existed to stop a dozen unrelated actors colliding as flat siblings: /user/devtools-hub/system/devtools/hub, and so on.
  • ClusterSingleton.start() returns an ActorRef; SingletonHandle is gone (#523) — system.extension(ClusterSingletonId).start(cluster, options)cluster.singleton.start(options); handle.proxy.tell(m)singletonRef.tell(m); handle.stop()cluster.singleton.stop(key); handle.managercluster.singleton.managerFor(key). withTypeName / withProps are unchanged. One behaviour change: stop() on the returned ref is now a warning no-op — it is the proxy, and ActorRef.stop() means "PoisonPill the target" everywhere else, which would have killed whatever the host was running.
  • Abbreviations spelled out across all identifiers — type, class, file, method, field, generic-parameter and local names use full words (Command/Message/Acknowledgment/Request/Response/Function/Context/Connection, no more Cmd/Msg/Ack/Req/Res/Fn/Ctx/Conn). Public surface is affected: generic parameters (PersistentActor<Command, Event, State>), Scheduler.scheduleOnceFunction / scheduleAtFixedRateFunction, exported types (HealthCheckFunction), config fields (maxMessages / maxAcknowledgmentPending / autoAcknowledge). The tagged-union discriminant is now always kind, never type, and its string values are spelled out (kind: 'increment', not 'inc'). Names mirroring external APIs (nats.js, prom-client, amqplib, DOM) and domain acronyms (PubSub, K8s, AMQP, MQTT, SQL) are unchanged.
  • Runtime floors raised: Node ≥ 24, Bun ≥ 1.3 — Node 20 reached end-of-life in April 2026; Node 24 is the oldest active LTS and the first floor on which the WebSocket client, zstd, WebCrypto and fetch are all native. The Bun floor moves from the two-year-old, never-CI-tested 1.1 claim to 1.3, which CI now actually exercises. Deno stays ≥ 2.0.
  • WebsocketClientActor always uses the native WebSocket — the dynamic ws fallback for Node < 22 is gone, and with it the headers client option (only that path could send custom handshake headers; on native runtimes it was already silently ignored). Migration: pass credentials via query parameter or subprotocol, as browser clients must. ws remains an optional peer for server-side upgrades on the Express backend.
  • typescript peerDependency is now ^5.6.0 || ^6.0.0 || ^7.0.0 — admits TypeScript 7 (the native compiler the repo itself builds with) and raises the floor from the never-verified 5.0 to 5.6.
  • Dead persistence options removed (#381) — LiveQueryOptions.batchSize, LiveQueryOptions.clock, the object-storage plugin's durableStatePluginId (+ builder method), and the HOCON key actor-ts.persistence.recovery.mode. All were declared but read by no code. Migration: remove any use — they were no-ops. (Cassandra's consistency is not removed; it is now honoured.)
  • Example wire protocols discriminate on kindexamples/cluster/counter-node.ts and the WebSocket frontends follow the project-wide convention.

🔒 Security

  • Actor names are validated (#126, #134) — closing a path-forging and a log-injection hole: a name containing a separator produced a path indistinguishable from a different actor, including across the cluster wire where the remote side re-splits the string.
  • HOCON config parsing can no longer reach the object prototype (#406).
  • ask reply refs get unpredictable names (#119) — the one-shot reply ref was named from a module-global ++askCounter: predictable enough to aim a forged reply at an in-flight ask, shared across every ActorSystem in the process, and prone to wrapping into collisions with names still in flight. Now 12 hex characters from crypto.randomUUID.
  • Numeric gossip and heartbeat fields are checked for plausibility (#113, #115) — a tombstone's removedAt failed open at Infinity/NaN, so one forged frame kept a node from ever rejoining.
  • A TCP socket's nested framing caps are validated (#372) — a non-numeric maxLineLen/maxFrameLen from HOCON did not clamp anything; every comparison against NaN is false, so it removed the DoS cap it exists to enforce.
  • The cluster-singleton proxy buffer is bounded (#526) — withBufferSize, default 1000, dropping the newest to dead letters past the cap. Unbounded, a cluster that never elects a host w...
Read more

v0.11.0

Choose a tag to compare

@pathosDev pathosDev released this 15 Jul 20:12
feaa8a0

The consistency + fail-fast release. A repo-wide naming sweep lands one vocabulary everywhere — no abbreviations, Options never Settings, Websocket casing — as hard cuts with migration notes. Underneath it, a new OptionsValidator / OptionsError layer makes every configurable thing fail fast on invalid values, on every input path (builder, plain object, HOCON). Plus scoped HTTP error handling, a security-middleware suite, static file serving, and a broad batch of WebSocket/HTTP security hardening from the audit backlog. Pre-1.0 — this minor carries breaking changes; see below.

🚀 New features

Options validation

  • OptionsValidator + OptionsError (#274) — a declarative-but-code validator layer for the XOptions pattern. Validation runs once at consume time on the merged settings, so builder, plain-object, and HOCON inputs are all checked and cross-field rules see the final values.
  • Validators shipped for ~40 options families — every broker (MQTT, Kafka, AMQP, Redis Streams, NATS, JetStream, SSE, TCP, UDP, gRPC client) and the WebSocket client; cluster core, sharding, singleton, and downing strategies; discovery + gossip intervals; leases; caches (RedisCache, MemcachedCache, CachedSnapshotStore, InMemoryCache); CassandraJournal and the S3 / filesystem object-storage backends; the Express/Hono HTTP backends; HTTP middleware + directives; WebSocket routes + resolved policy; WorkerCluster, ProducerController, TestProbe, CircuitBreaker, and BoundedMailbox.
  • RateLimitOptions / IdempotencyOptions fluent buildersrateLimit and idempotent gained the real builders they were already documented to have; the plain-object call form is unchanged.
  • withMaxDecompressedBytes store option — the 512 MiB decompression-bomb guard (#3) is now tunable per object-storage store (Infinity opts out).
  • Per-route WebSocket connection cap — opt-in maxConnections on websocket() routes (builder or actor-ts.http.websocket.maxConnections); upgrades beyond the cap close with 1013 before an actor is wired.

HTTP

  • Scoped error handling + fallback routes (#352) — handleErrors(handler, child) catches exceptions from a subtree, fallback(handler) answers unmatched requests, ServerBuilder.withErrorHandler(...) is the server-wide last resort. Uniform precedence across Fastify/Express/Hono.
  • Security-middleware suite (#353) — cors (compiler-expanded per-pattern preflight routes), strictTransportSecurity/hsts, contentSecurityPolicy, csrfProtection + requireSameOrigin, securityHeaders, requestId, BasicAuth, requestTimeout — each with an XOptions builder. Plus public parseCookies / serializeCookie.
  • Static file serving (#354) — getFromFile, getFromDirectory, getFromBrowseableDirectory: MIME detection, conditional requests (weak ETag + Last-Modified → 304), single Range (206/416), HEAD, and XSS-safe listings. Plus a MIME-type registry (contentTypeFor) and streaming response bodies (ReadableStream<Uint8Array>) on all three backends.
  • HTML response utilities (#352) — escapeHtml, an auto-escaping html tagged template with a SafeHtml brand, rawHtml, completeHtml.

⚠️ Breaking changes (pre-1.0)

  • WebSocket → Websocket (single-cap), no Ws abbreviationWebSocketServerActor/WebSocketClientActorWebsocketServerActor/WebsocketClientActor, Ws* supporting types → Websocket*, wsSend()websocketSend(), module moved src/http/ws/src/http/websocket/. The websocket() directive, the global WebSocket, and the Sec-WebSocket-Protocol header are unchanged.
  • Abbreviations spelled out in type/member names: *Cmd*Command, *Msg*Message, *Ack*Acknowledgment, ByPid*ByPersistenceId*, *Impl*Implementation, *Ctor*Constructor. Testkit too: TestProbe.expectMsg()expectMessage(), expectMsgType()expectMessageType(). Wire/discriminator string literals are unchanged.
  • One config vocabulary: Options, never Settings — remaining *Settings types → *OptionsType; BrokerSettings.ts folded into BrokerOptions.ts (BrokerSettingsErrorBrokerOptionsError); the BrokerActor glue renamed (readOptionsFromConfig / requiredOptions / builtInDefaultOptions / options).
  • Command vs Signal unified on kind — MQTT and WebSocket internal mailbox signals are kind-tagged plain objects; the bad-payload hook is onInvalidMessage everywhere (MQTT's onDecodeError is gone); WebSocketAcceptSignalWebsocketAcceptCommand.
  • Invalid option values throw OptionsError (#274) at construction / actor start instead of a bare Error — and previously-unchecked builder/plain-object paths are now checked. Missing required broker settings still throw BrokerOptionsError; malformed HOCON still throws ConfigError.
  • InMemoryCache joins the XOptions familyInMemoryCacheOptions builder + validator + HOCON defaults under actor-ts.cache.in-memory; the internal InMemoryCacheSettings interface is removed (a plain { maxEntries, cleanupMs } object still works).
  • CircuitBreaker + BoundedMailbox validate their optionsOptionsError instead of bare Error; maxFailures/resetTimeoutMs and capacity are required at runtime (a builder without them previously produced a breaker that never opened / an unbounded "bounded" mailbox); callTimeoutMs: 0 now throws (omit it to disable).
  • HTTP structural types — the Route / CompiledEndpoint unions gain fallback and cors variants (exhaustive matches must handle them); ServerBuilder gains a required withErrorHandler; HttpError gains an optional 4th headers parameter and BearerTokenAuth 401s expose the challenge on err.headers['www-authenticate'] (#352, #353).

🔒 Security

  • WS-1 (HIGH) — WebSocket upgrade crash hardened — a malformed percent-escape in the upgrade path was process-fatal (unhandled rejection) on the Express backend, reachable pre-auth. Now a non-match → 404, with a last-resort socket guard.
  • WS-2 (HIGH) — Cross-Site WebSocket Hijacking (CSWSH) defence — new allowedOrigins on websocket() routes: a listed-but-wrong Origin is rejected with 403 before the handshake on all three backends.
  • HTTP-1 (MEDIUM-HIGH) — Hono body-size cap enforced before buffering — oversized Content-Length now rejects with 413 before reading the body.
  • HTTP-2 (MEDIUM-HIGH) — InMemoryCache is bounded (LRU) — attacker-chosen keys (idempotency, rate-limit) can no longer grow the default cache without limit; defaults maxEntries: 10_000, background sweep every 60 s.
  • HTTP-4 (MEDIUM) — idempotency responses can be scoped per caller — opt-in identity: (req) => string folds the authenticated principal into the cache key, preventing cross-user response disclosure.
  • #3 (MEDIUM) — decompression-bomb cap on stored bodiesdecodeBody caps decompressed size at 512 MiB by default, now tunable per store.
  • WS-3 (MEDIUM) — transport-level WebSocket frame cap (Express + Fastify) — oversized frames are rejected at the protocol level (1 MiB) instead of being buffered in full first.
  • WS-4 (MEDIUM) — WebSocket backpressure works on Hono — the adapter now surfaces bufferedAmount, so maxBufferedBytes / onBackpressure are no longer no-ops there.
  • WS-5 (MEDIUM, partial) — per-route connection admission cap — see New features; the handshake/idle-timeout and hub-mailbox-bounding sub-parts remain tracked follow-ups.
  • WS-6 (LOW) — CRLF stripped from raw upgrade-reject headers — no response splitting via app-supplied header values on the Express reject path.
  • BRK-1 / BRK-2 (MEDIUM) — inbound buffer caps for TCP lines + SSE — un-delimited streams now drop the connection instead of buffering forever.
  • #6 (LOW) — consistent SQL/CQL identifier validation — SQLite and Cassandra now validate config-sourced identifiers like Postgres/MariaDB already did.
  • #9 (hardening) — JSON deserialization ignores the __proto__ setter — hostile payloads can't change a decoded object's prototype.
  • CORS, CSRF, and security-header middleware (#353) — correct preflight handling, HMAC-signed double-submit CSRF token, HSTS/CSP/COOP/CORP/nosniff/frame-options, constant-time secret comparisons; WWW-Authenticate challenges now reach the wire.
  • Hardened path-traversal defence for static files (#354) — full pre-validation decode, segment rejection (.., NUL, backslash, drive/ADS), symlink confinement, uniform 404s.

📚 Documentation

  • Server-WebSocket page moved from IO into the HTTP section (#351); stale API-reference pages reconciled with the shipped code (#360).
  • Middleware pages corrected to actual behavior: rate-limit headers + keying on req.remoteAddress (HTTP-3), idempotency in-flight → 409, response-cache single-flight (EN + DE).
  • The reference config now documents the actor-ts.http.websocket policy section; *Settings prose repointed to the *OptionsType vocabulary (#349).

Full changelog: CHANGELOG.md · 2665 tests green, ~94 % line coverage.

v0.10.0

Choose a tag to compare

@pathosDev pathosDev released this 08 Jul 08:42
bbd3ab7

The typed real-time + SQL-persistence release. Two new first-class actor families — a typed WebSocket stack (server routing + client) and a subclass-first MqttActor — land alongside PostgreSQL and MariaDB persistence backends. Configuration gets a framework-wide overhaul: every configurable thing now takes a fluent builder or a plain object under one XOptions name, with no more "Settings vs Options" split. Plus HTTP middleware + auth, structured logging, a real-network multi-node integration harness, and five security hardening fixes. Pre-1.0 — this minor carries breaking changes; see below.

🚀 New features

Messaging & IO

  • Typed WebSocket routing (#1) — websocket(path, actorRef) binds a WebSocketServerActor<TOut, TIn>: codec-decoded messages (JSON default, rawCodec() for binary), this.reply(...) / this.broadcast(...), and onClientConnected / onClientDisconnected / onInvalidMessage hooks. A session actor per connection solves the first-frame race by construction. Works on Fastify, Express, and Hono (Bun/Node/Deno); withMiddleware(...) gates the handshake.
  • WebSocketClientActor<TOut, TIn> (#1) — typed client on BrokerActor: reconnect-with-backoff, outbound buffering across reconnects, circuit breaker, HOCON settings.
  • Subclass-first typed MqttActor<T, TSelf> (#345) — extend it, declare subscriptions in the constructor (this.subscribe(topic, { qos })), handle inbound in onMessage, publish with this.publish(...). Lifecycle events run on the actor thread; still externally controllable via ref.tell({ kind: 'publish' | 'subscribe' | 'unsubscribe', … }).
  • Typed MQTT payloads + codec (#345) — inbound MqttMessage<T> carries a lazily-decoding MqttPayload<T> (.bytes / .text() / .entity<U>()); pluggable MqttCodec<T> (default mqttJsonCodec()).

Persistence

  • PostgreSQL backend (#323) — PostgresJournal, PostgresSnapshotStore, PostgresDurableStateStore (first SQL durable-state store) on pg, via registerPostgresPlugins. Optimistic concurrency, indexed tags join, auto-created schema. pg is an optional peer dep; in-process fake-pool suite + live postgres:latest CI.
  • MariaDB backend (#324) — MariaDbJournal / MariaDbSnapshotStore / MariaDbDurableStateStore + registerMariaDbPlugins via the mariadb connector (MariaDB dialect). Optional peer dep; fake-pool + live mariadb:latest CI.
  • Configurable compression level (#322) — CompressionConfig.level (gzip 0–9, zstd 1–22), encoder-only, no migration (old + new bodies mix freely).

HTTP

  • Route middleware framework (#312) — withMiddleware(mw, route) + a Middleware type, composing outside-in.
  • BearerTokenAuth({ tokens }) (#312) — constant-time bearer-token gate with WWW-Authenticate.
  • IpAllowlist({ allow }) (#312) — CIDR (IPv4 + IPv6, incl. IPv4-mapped) network isolation, fail-secure.
  • HttpRequest.remoteAddress + backend wiring (Fastify/Express/Hono) so IpAllowlist works on real socket peers (#312).
  • managementRoutes auth (#312) — auth, ipAllowlist, authProtectHealth; privileged subtree gated, /health + /ready anonymous by default.

Observability

  • JsonLogger (#311) — one JSON object per record to stdout, MDC-aware, never-throws sanitisation.
  • otelLogger({ api }) (#311) — OTLP-Logs bridge via @opentelemetry/api-logs, auto-links the active span.

Testing

  • Real-network multi-node integration tests (#313) — a Docker-compose harness (5 node containers + controller) running 15 scenarios (partition/heal, sharding rebalance, singleton failover, CRDT convergence, external ClusterClient, management auth, CoordinatedShutdown, bounded-mailbox drops, DNS discovery, …) with iptables + tc netem fault injection. bun run test:integration.

⚠️ Breaking changes (pre-1.0)

  • Options: a fluent builder OR a plain object, under one XOptions name (#346, #348) — every configurable type exposes three names from one XOptions.ts: XOptionsType (plain object), XOptionsBuilder (fluent builder), and XOptions (the union every consumer accepts, plus a value alias so XOptions.create() works). new MqttActor(MqttOptions.create().withClientId('x')) and new MqttActor({ clientId: 'x' }) are interchangeable. The old XSettings interface is renamed XOptionsType and the builder class XOptions is renamed XOptionsBuilder; everyday call sites (XOptions.create()…, plain objects) are unaffected — only code referencing the old XSettings type name or the builder class by name needs updating. HOCON precedence is unchanged.
  • Renamed settings fields + HOCON keys (#348) — MQTT defaultQosqos, keepAliveSeckeepAlive; JetStream ackTimeoutMsackTimeout; ClusterClient loglogger; DistributedData gossipIntervalMsgossipInterval; ProducerController resendTimeoutMsresendTimeout.
  • MqttActor is now abstract (#345) — subclass and override onMessage.
  • MqttMessage.payload is a MqttPayload<T> wrapper (#345), not a raw Uint8Array: msg.payload.bytes / .text() / .entity().
  • Removed MqttOptionsType.subscriptions + MqttSubscription (#345) — move them into the subclass constructor as this.subscribe(topic, { target }).
  • subscribe / unsubscribe target semantics (#345) — a no-target subscribe delivers to the actor's own onMessage; a no-target unsubscribe removes only foreign targets.
  • Bounded mailbox is the default (#310) — capacity 10 000, drop-head; drops counted via actor_mailbox_dropped_total. Opt back into unbounded per-actor with Props.withMailbox(() => new Mailbox()).
  • Removed the legacy frame-level WebSocket APIWebSocketActor, ServerWebSocketActor, and the serverWebSocketActorOf / bunWebSocketHandlers adapters are gone; use WebSocketClientActor and websocket(path, ref) + WebSocketServerActor. The client HOCON key actor-ts.io.broker.websocket is unchanged.

🔒 Security

  • WebSocket DoS hardening (#1) — inbound frames size-capped (maxFrameBytes, 1 MiB default) before decode; oversize → close 1009, undecodable → close 1003; slow-consumer backpressure past maxBufferedBytes.
  • DurableState revision tampering (#116) — opt-in HMAC-SHA256 over { revision, etag } for unencrypted object-storage bodies (encrypted bodies already bind revision as AES-GCM AAD); requireIntegrity refuses legacy un-tagged bodies.
  • ClusterClient ask-ID predictability (#120) — nextAskId() now crypto.randomUUID() (was Date.now() + counter).
  • Master-key rotation sweep race (#109) — durable resume tokens (progress) + pre-sweep verifyKeyringCompleteness.
  • LeaseMajority split-brain (#142) — monotonic acquireEpoch, release-on-abandon with fail-safe, and optional fencing tokens (Lease.acquireWithToken?()).

🐛 Fixed

  • MQTT subscriptions re-applied after reconnect (#345) — runtime subscriptions no longer silently stop receiving after a drop.
  • MQTT subscribe while disconnected reaches the broker on connect (#345).
  • MQTT terminated fan-out targets cleaned up (#345) — deathwatched refs pruned; UNSUBSCRIBE fires once a pattern has no consumers.
  • zstd compress on runtimes without native zstd (#321) — compress is native-only with a clear "needs Bun / Node ≥ 22.15" error; decompress keeps the fzstd fallback; the misconfig now surfaces at plugin-init.
  • Object-storage compression docs corrected (EN + DE) to the real none / gzip / zstd set, ATS1-manifest decode, and level option.