Skip to content

Releases: C9up/relay

v0.1.25

Choose a tag to compare

@github-actions github-actions released this 01 Oct 10:46

release: relay 0.1.25

Name every relay code E_RELAY_*

E_NOT_OWNER, E_UNKNOWN_CONNECTION, E_NOT_CONNECTED, E_MAX_CHANNELS,
E_CHANNEL_FORBIDDEN, E_MAX_CONNECTIONS, E_MAX_CLIENTS, E_CHANNEL_TOO_LONG
and E_CHANNEL_NO_AUTHORIZER take the E_RELAY_ namespace the other relay
codes already had. Transmit answers with bare statuses, so there is no
upstream identifier to keep.

Close the Relay audit: SignalR handshake, bus echo, one uid per connection

SignalR reads its connection token from url(true): Ream leaves the query
string out of url(), so every valid connection got E_UNKNOWN_CONNECTION.
onConnect runs once the handshake is answered, since a frame ahead of the
handshake reply makes the SignalR client refuse the connection. Hub guards
read auth.authenticatedViaGuard, where Warden reports the guard. Reopening a
token closes the older stream, a stream that never handshakes is closed
after handshakeTimeoutMs, and past maxConnections a new one gets 503. A
handler's return value is carried in its Completion.

A broadcast carries the id of the relay that published it, which ignores
its own on the bus: the bus hands a publication back to its publisher, and
local clients received it twice.

Every connection gets its own server-issued uid, with the owner checked on
each subscribe, so two tabs of one account no longer evict each other, and
a late failure of an old stream can only drop that stream. The ?uid= hint
and E_UID_HIJACK go with it.

The channel cap is checked again after the authorizer's await, an async
observer's rejection is caught, the subscribe routes hand authorizers the
whole context (ctx.bouncer works), and pingInterval takes a duration.


Changes since v0.1.24.

v0.1.24

Choose a tag to compare

@github-actions github-actions released this 23 Sep 17:32

Build against the published ream, require 0.2.26 at run time

The devDependency pinned ^0.2.26, which is not on npm, so pnpm install
failed before a single test ran and CI could not say whether the code was
good. Nothing here imports a symbol that 0.2.26 added: the only reference
is the @c9up/ream/types module augmentation, which 0.2.25 carries.

The peer stays ^0.2.26 — that is where configure()'s makeUsingStub
requirement belongs — and the caret picks 0.2.26 up on its own once it is
published.

Generate the config from a stub

The file this package writes lived as a template literal inside its own
TypeScript — every backtick and every ${ escaped, and no way for an
application to change it without forking the package.

It is a stub now, read through codemods.makeUsingStub, the same route
ream-cli has always used for the make: generators. An application that
publishes stubs/<path> gets its copy instead, and the generated file is
byte-identical to what the literal produced.

The ream peer moves to ^0.2.26: that is the release the codemod appears in.


Changes since v0.1.23.

v0.1.23

Choose a tag to compare

@github-actions github-actions released this 22 Sep 17:01

release: relay 0.1.23

Require Node 24, and build the crates for production

Node 24, not because it is the current LTS — that is AdonisJS v7's own
stated reason and it is not one for us, since 22 still receives security
fixes until 2027, npm 11 is irrelevant under pnpm, and node:sqlite is
not what atlas uses. The reason is measurable and it is the framework's:
AsyncLocalStorage is on the request hot path, and Node 24 backs it with
AsyncContextFrame by default — 0.61 us per request instead of 1.55 us on
that exact pattern. Before 24 the same mechanism sat behind an
experimental flag, and a framework cannot base its performance on a flag
the application has to remember to pass. The reason travels with the
constraint, in a "//engines" key beside it.

Where there are crates: the default release profile leaves lto = false
and codegen-units = 16, so nothing inlines across crate boundaries —
and here the hot loop and the N-API binding that calls it are always two
different crates. Measured on atom, a scalar call through the binding
went from 18.85 ms to 13.59 ms for 50 000 operations. No panic = "abort": napi-rs catches panics and turns them into JavaScript
exceptions.

CI moves to Node 24 with them, since that is what the packages now ask
for.


Changes since v0.1.22.

v0.1.22

Choose a tag to compare

@github-actions github-actions released this 10 Sep 14:16

Wait on the callbacks, and let a synchronous throw win

The transport awaited client.subscribe(...) and then read a flag onError
may have set. quasar answers void now, so that flag was read before anything
had been attempted. The callbacks are the signal, and correct against both
quasar majors — the range admits either.

A callback that fires DURING the call is held until the call returns, so a
client that registers the handler and then throws synchronously counts as the
failure it is. Announcing success on the way to throwing must not win, and a
test already demanded exactly that.

Keep the dev-dependency alignment, drop the workspace: protocol

The internal ranges had been rewritten to workspace:^. That resolves inside
this monorepo and nowhere else: every package CI checks out its own repository
alone and runs pnpm install, where the protocol has no workspace to point at
and fails with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND before a single test runs. The
concrete ranges are back; the dev-dependency bumps that came with the same edit
are kept, and now match what the lockfile already resolved.


Changes since v0.1.21.

v0.1.21

Choose a tag to compare

@github-actions github-actions released this 09 Sep 16:26

fix: an unreleased subscription is an obligation, retryable and blocking

A client can register the callback and THEN fail, and the transport's own
undo can fail too. Relay forgot its handler on the error and had nothing to
point at, so the next start subscribed beside a listener still live: two
handlers, every message twice, and only the newer nameable at shutdown. The
previous round covered a subscription already established; this covers a
first subscribe that half succeeded.

Both failure paths — a shutdown that could not unsubscribe, and a subscribe
whose undo failed — now record a RETRYABLE release. A settled rejected
promise would refuse every start for the life of the process, and the bus
recovering is exactly the case that has to work: the start retries the
release, clears the obligation on success, and refuses while it stands.

RedisRelayTransport keeps every wrapper per handler rather than one.
Subscribing the same callback twice registers two listeners with the client;
keeping one meant the second overwrote the only way to name the first, and an
unsubscribe removed one of the two. The class is exported, so this was not
limited to relay's own use of it.

And disconnect() removes the listeners it put on the bus whoever owns the
connection. A borrowed one is not relay's to close — but the listeners are
relay's to remove, and returning early left a shutdown that released nothing
at all on a connection the application shares.

Four mutations, each falling on its own test.

fix: a failed teardown stops the next start, and a failed subscribe is undone

Two ways relay could end up with two handlers on one bus.

startTransport() waited for a teardown in progress with
await teardown.catch(() => {}) — so a shutdown whose unsubscribe had FAILED
was treated as done, and the start subscribed beside a handler still
listening. Every message was then delivered twice, and the next shutdown
could only name the newer one. The start now refuses, and says why: a relay
that cannot release its previous subscription cannot know what is still
listening. #transportHandler is kept when the removal was refused, so a
later shutdown still has something to name, and the retry clears the state.

RedisRelayTransport.subscribe recorded the wrapper before calling the
client and forgot it on failure. A client is free to register the callback
and THEN fail — an ambiguous network result, or a duck-typed adapter that
does its bookkeeping first — and the wrapper was then live and unnameable.
The failure path takes it back off; if that fails too the record stays,
because it is the only way a shutdown can name what may be listening.

Two existing tests asserted the client was never reached after a failed
subscribe. They now distinguish the two moments: the failure path DOES reach
it once, to undo what it may have done, and a later unsubscribe still adds
nothing.

Three mutations, each falling on its own test.

fix: a restart must outlive the shutdown it overlapped, and bookkeeping must survive a refusal

Two findings, both about state that outlives the call that owns it.

shutdown() checks the generation BEFORE it awaits disconnect(), so a
restart landing during that await passes a check that has already happened —
and the old shutdown goes on to close the connection the new generation has
just subscribed on. Relay then reports itself started, with a live handler,
on a transport nobody can reach. A start now waits for a teardown still in
progress.

I removed this guard in the previous round because I could not make it fall
a test. It falls one now: hold the transport's disconnect, restart during
it, and the restart's client receives nothing.

RedisRelayTransport recorded the wrapper before calling the client and
forgot it only when quasar REPORTED a failure. A direct rejection left it
recorded — so a later unsubscribe asked the client to remove something it may
never have registered, and a retry was refused by bookkeeping for a
subscription that never happened. The other direction had the mirror defect:
the record was dropped before the client was asked, so a rejecting
unsubscribe left a live subscription nothing could name again, neither to
retry nor to remove at shutdown. Each entry is now forgotten only once the
client has accepted its removal, one at a time.

Three mutations, each falling on its own test.

fix: name the handler, because the bus stacks subscriptions

The generation fix rested on an assumption I wrote down and did not check:
that the transport holds one handler per channel, so a superseded start
attempt had to leave its subscription alone rather than take down the live
one. The bus relay actually runs on does the opposite — @c9up/quasar
stacks handlers in a Set per channel, matching upstream — and my test
hid it by modelling the transport as a Map, where the second subscribe
silently replaced the first.

With the real shape, a superseded attempt left its handler delivering
alongside the live one: every broadcast reached each client twice, for the
life of the process.

RelayTransport.unsubscribe now takes the handler, as quasar's own does.
RedisRelayTransport keeps a wrapper per registration instead of per
channel, so it can remove exactly one. Each start attempt owns its handler
and undoes that one when it turns out to have been superseded.

shutdown() unsubscribes only a handler it has live. Asking for "every
handler on this channel" silenced another Relay sharing the same bus, and
there is nothing left to sweep now that every attempt cleans up after
itself.

RedisRelayTransport.disconnect() forgets the connection before closing
it. Cached, the quit client was handed straight to the next subscribe, so
an application that stopped and started again in one process came up on a
socket that was already shut. And shutdown() skips the disconnect when a
newer generation has started, rather than closing the bus underneath it.

The test bus now stacks handlers like quasar's. Four mutations, each
falling on its own tests. A fifth guard — serialising a restart behind the
teardown — could not be made to fall anything: with the generation check,
the named handler and the invalidated connection, every interleaving is
already covered, so it is not in the patch.

fix: a start in flight must not be undone by, or undo, a shutdown

A subscribe is asynchronous, so a shutdown or a restart can land while one
is still under way. Two things went wrong when it did.

A subscription that completed AFTER a shutdown stayed on the bus. The
shutdown had already looked for a handler to remove and found none, so the
handler that landed a moment later was untracked: it kept re-delivering
every remote broadcast to streams the shutdown was meant to release, and no
later shutdown could name it either.

A failure reported after a restart tore down the attempt that replaced it.
The catch cleared the NEW attempt's ping timer and marked the relay stopped
while its subscription was live, so the next start subscribed a second time
to a channel it was already on and every broadcast arrived twice.

Both are the same missing notion: which attempt owns the transport.
#transportGeneration is incremented by every start and every stop, and an
attempt only touches shared state — or undoes its own subscription — while
it is still the generation that owns it.

shutdown() deliberately does NOT wait for an in-flight start: moving the
generation on is what tells that attempt to take its own subscription back
down, and waiting instead would hang a graceful shutdown on exactly the
unreachable bus that makes shutting down urgent.

Four tests, each falling on its own guard: removing the post-subscribe
generation check fails the two shutdown tests and only those; removing the
guard in the catch fails the two restart tests and only those.

fix: a failed or stopped transport must leave relay startable

#transportStarted was set BEFORE the subscribe, so a failure was permanent:
every later call handed back the same rejected promise without ever trying
again, and the ping timer kept running for a bus this instance was not on. A
Redis that came back could not be rejoined.

Nothing was reset on shutdown either, so an application that stopped and started
again in one process — a hot reload, a test — sat silently off the bus while
believing it had subscribed.

A failed start now undoes itself but KEEPS its rejection: startable again is not
the same as nothing happened, and a provider deciding whether to boot needs the
reason. whenTransportReady() refuses to answer at all before a start, which is
the one answer it must never give — it resolved immediately, contradicting the
contract it documents.

And disconnect() runs in a finally: an unsubscribe that rejected skipped it
and leaked the connection relay owns.

docs: the preloads run AFTER the providers start, not before

Upstream's warm-up is providers.start() -> the 'starting' hooks -> the preloads.
These comments said the opposite and placed advice on it.

fix: open the bus in ready(), and never unsubscribe what was never subscribed

Constructing a Relay opened a Redis socket and started a timer. register,
boot and start all run when an application is assembled to be INSPECTED — a
route listing, a codegen pass — and shutdown does not run on that path. So
ream inspect opened a connection nothing would ever close. Upstream reserves
ready for sockets and background work; startTransport() is called from there,
it is idempotent, and the constructor now does no I/O at all.

unsubscribe reached the client even with nothing to remove. Passing no
handler means "drop every listener on this channel" on a shared connection, so
calling it when relay never subscribed — after a failed ready(), or on a
second shutdown — cut the cache's and the sessions' listeners instead of
relay's. It now returns before touching the client, which also stop...

Read more

v0.1.20

Choose a tag to compare

@github-actions github-actions released this 06 Sep 19:48

Name the CI workflow after the package

Every workflow already declared name: <pkg>-ci inside — twenty-eight of
twenty-nine — while the file was ci.yml almost everywhere and
<pkg>-napi-ci.yml in three places, where the -napi said nothing: half the
packages with a Rust engine did not carry it.

The file now matches the name it has always had, so one rule covers every
repository and the publish command no longer depends on remembering which
three were spelled differently.

GitHub keys run history by file path, so the runs recorded under the old name
stay reachable under it and this workflow starts a fresh history.

Keep the vendored copies out of this package's coverage floor

src/vendor/** is generated and identical in every package that carries it, so
measuring it here counts the same lines N times and holds this package to a
floor for code it cannot change — which is what pushed several suites under
their thresholds the moment the copies landed.

The behaviour is not left unmeasured: it is pinned where it broke, in bay's
quasar-bridge suite, which now covers both manager shapes the loader has to
accept.

Take the quasar loader from the vendored copy

Seven packages carried the same optional-peer loader: the runtime specifier,
the manager guard, the command check and the messages around them. Only three
things differed — the commands each issues, what it does with them, and how it
builds an error — so those are passed in and the rest is generated from
scripts/vendor/quasarConnection.ts.

Two things the packages' own tests caught while it was being unified, and both
are now properties of the shared copy rather than of one package:

The module namespace is probed with in before it is read. Reading an export a
namespace does not have is not always harmless — under a test double it raises
instead of answering undefined, so the probe failed on the mock rather than
falling through to the default export.

The error is built by the caller. nova raises NovaError with E_NOVA_* codes
that a caller catches on, and a shared helper throwing a bare Error would have
dropped them silently. Packages that offer a client object instead of a
connection name keep saying so, too: unifying the wording had removed the
alternative from the one message where it was actionable.

Release the relay when the application stops

shutdown() released the bus subscription and left the instance seated, so a
Relay reachable through services/main accepted a broadcast and delivered it to
a transport it had already let go of. It clears its own now, and only its
own.

Mount the routes where the application declares them

The provider built the SSE endpoints and the hub routes in ready(), which runs
after the HTTP server is already listening. A request arriving in that window
answered 404 from a route the application had asked for. ready() was chosen
because providers start before preloads, and a preload is where an app writes
registerRoutes() and hub() — but that only moved the problem past the listen.

registerRoutes() and hub() now mount as they are called, which is what
upstream's Transmit does: its provider registers no route at all, and
transmit.registerRoutes() builds them itself. A preload runs before the socket
opens, so the window closes rather than shrinking.

BREAKING — an application that never calls relay.registerRoutes() no longer
gets /__relay/events. Upstream is the same. A hub mounted without it is
reported at ready(), since no client can reach a hub whose event stream does
not exist.


Changes since v0.1.19.

v0.1.19

Choose a tag to compare

@github-actions github-actions released this 06 Sep 07:47

Release 0.1.19

Keep what a preload registered, and register the routes late enough to see it

Two halves of the same gap, both of which made relay.authorize(...) in a
preload answer E_CHANNEL_NO_AUTHORIZER at request time.

services/main hands out a Proxy that builds a default Relay on first
property access, so an application with no provider still has a usable
surface. The provider then builds its own — configured — instance and
takes the slot, leaving everything already registered on the default with
an object nothing serves from. Nothing said so: both calls read as
relay.authorize(...), and getRelay() returned the served one.

setRelay now moves those registrations across — authorizers, the route
customizer, mounted hubs and lifecycle listeners. Only from the lazy
default, and only for a pattern the served instance has not declared
itself: replacing a real instance with another (a hot reload, a second
Ignitor under test) must hand over a clean one rather than drag the
previous application's rules into it.

And the routes move from start() to ready(). The provider's comment
claimed they were registered "AFTER preloads have run", which is not the
order: the host starts every provider first, precisely so a preload finds
a built container. So registerRoutes(customizer) written in
start/services.ts could never reach the routes it decorates — they were
already built. ready() runs after the preloads, and adding routes there
is safe: the router builds its lookup index lazily and rebuilds it when
the table changes.

Lint this package the way its own repository will

biome's configuration lived only at the workspace root. This package is
built from its own repository, where that file does not exist and biome
falls back to its defaults — so lint in CI has been checking a different
set of rules from lint here, and the bans this project actually cares
about were never enforced where it counts.

The config is now the package's own, and says the same thing the root one
did.

Declare what CI has to install

Each package is its own repository: pnpm install there sees only this
file, so a dependency the workspace happened to hoist locally is simply
absent in CI. --coverage needs @vitest/coverage-v8 named here, and an
optional peer a test imports has to be a devDependency as well — optional
is exactly what keeps it from being installed.

Lint the tests, and read a sent frame by name

Six sites did JSON.parse(sent[n]?.toString() ?? "") and cast the result
to reach .code: the optional chain reads like a guard, and the cast is
what keeps the compiler from asking. They go through an errorCode(sent, nth) helper that says which frame it means and fails with that name.

Plus the rest of the gates the package already declared.


Changes since v0.1.18.

v0.1.18

Choose a tag to compare

@github-actions github-actions released this 04 Sep 15:27

Write down that roles are ANY and permissions are ALL

Reformat what the strictness pass reflowed

Two files-worth of blank lines and one long call the formatter wraps
differently now that a helper sits above them. CI resolves biome from a
caret range and installs a newer one than the lockfile pins.

Turn on noUncheckedIndexedAccess

It was not missing here — it was explicitly false, in sixteen of the
seventeen tsconfigs. eon alone had it on, which is why nobody had seen
what it finds.

It stays a named deviation from upstream: @adonisjs/tsconfig sets
strictNullChecks and noImplicitAny but not this one. We keep it because
turning it on is what caught an as asserting a possibly-absent regex
group was a known value — the exact shape the flag exists to find. Doing
better than upstream is kept and written down, not reverted to parity.

Every site is restated rather than silenced: no !, no cast, no ?? 0
standing in for a branch that cannot happen. A reversed copy read by
value where an index walked a callback list backwards, the winner of a
scan kept as the value it found rather than its position, destructuring
where a length check was doing the proving, and an explicit break where a
loop condition already bounds the read.

Say what container.make() returns for the tokens this package binds

ream declares ContainerBindings open on purpose: it registers its own
entries and expects each package to contribute the ones it owns — its
comment on the interface names auth (warden), logger (spectrum) and db
(atlas) as exactly this. None of them did, and every other package that
binds a string token was in the same state, so container.make('cache'),
make('mail'), make('hash') and the rest all answered unknown and
every call site had to assert a type it could not prove.

Loaded from the barrel AND from the provider, the second of which is where
AdonisJS puts its own (providers/redis_provider.ts carries the
declare module for redis, database_provider.ts for lucid.db).

Verified live rather than assumed: a declare module naming a specifier
that does not resolve is silently inert, so renaming the member has to
break the compile. It does.

Take the transport key in the shape upstream gives it

transport is { driver, channel? }, and null for no bus — the shape
@boringnode/transmit declares (TransmitConfig.transport). The driver
on its own, with transportChannel beside it, is the flat form this
config had before and still resolves; the block's channel wins when both
are written, since it is the one attached to the transport it configures.

Also removes a branch that could not fire: #checkDispatchAuth read
#guards.guard, which useGuards never writes — it folds the singular
into guards — so the normalisation had two homes and one of them was
dead. And writes down why roles are any-of while permissions are all-of:
it is the rule the HTTP middleware, the RPC router and the GraphQL guard
all apply, so a hub agrees with the route next to it.

Release 0.1.18

Stop trusting a token, a body, or a prototype level

Seven defects, most of them on the half of a connection's life nothing
tested: after the client is gone, or when the request is not the one that
opened it.

An authorizer that awaits — a database lookup is the usual one — could
resolve after its socket closed. Writing the subscription then put the uid
back into the channel index for a client that no longer existed, and nothing
could take it out again: dropping a client walks that client's own channel
set. The entry stayed for the life of the process, counted, returned and
walked on every broadcast; on a reconnect it attached the old subscription
to the new connection, which had never asked for that channel.

subscribe and unsubscribe took their uid and channel off a JSON body and
believed the signature. A channel that arrived as an object reached the
pattern matcher and threw channel.split is not a function out of the
route: a 500 and a stack trace for a body anyone can post. Both arguments
are unknown now, and the relay decides.

SignalR negotiate is an unauthenticated POST that wrote a token entry, and
only a stream closing ever removed one. A caller that never opened a stream
left it forever. Tokens now expire unclaimed and are capped, the way
SignalR's own server disposes a connection whose transport never attaches.

Holding a connectionToken was enough to invoke as the connection's identity
— and it travels in a query string, so it reaches access logs and proxies.
Both hub routes now apply the ownership rule the relay already applies to
its own uid. A stale stream's close no longer tears down the connection
that replaced it, and re-registering a client id no longer hands the new
connection the groups the old one had joined.

The handler allowlist read one prototype level, so a hub extending another
hub answered its inherited handlers with E_RELAY_UNKNOWN_EVENT. It walks
the chain down to Hub.prototype now, which is where the allowlist's actual
purpose starts.

Parity with @adonisjs/transmit 3.0.2 and @boringnode/transmit 0.4.0, read
from the tarballs: pingInterval was missing entirely, and an idle SSE stream
is one nginx closes at sixty seconds. An absent payload is carried as null,
as upstream does, so it does not mean three different things depending on
where the listener runs. allowUnauthorizedChannels is a named deviation —
upstream allows a channel no authorizer covers; we refuse it — and the
comment claiming it was upstream's default is corrected.

Also: an invocation carrying an invocationId is always completed, a failed
handshake closes the connection, a Close from the client closes the stream,
one client's throwing transport no longer silences the rest of its group,
and the README no longer says hubs have no server transport when they do.

Three rejections that escaped, one of them out of the constructor

Promise.resolve(transport.subscribe(...)) calls subscribe FIRST, so a
transport that threw synchronously — a bad URL, a client built without a
connection — came straight out of new Relay(...) and never reached the catch
the comment promised. Called inside the async function now, as quasar's
dispatch already is.

A connected frame that fails leaves the client without the uid it needs to
subscribe or unsubscribe, so it is dropped rather than kept as a silent tenant
of the client map; unhandled, that rejection also ended the process. The
SignalR hub send had the same shape.

Both are pinned by tests that fail against the previous source.


Changes since v0.1.17.

v0.1.17

Choose a tag to compare

@github-actions github-actions released this 01 Sep 15:38

Turn on noUnusedLocals/noUnusedParameters

Drop a client whose delivery rejects instead of leaving the rejection unhandled

An SSE write that rejects had nowhere to go: on a default Node that ends the
process, so one dead socket ended the broadcast for every other client. The
same was true of a bus publish, where the failure also means the other
instances never hear the message — the split-brain a failed subscribe already
reported.

Say when the transport subscribe fails

An instance whose subscribe rejects keeps serving its own clients and quietly
misses everything published elsewhere — a split brain with no symptom. The
call was unawaited, so the rejection had nowhere to go and nothing was
printed.

Reported now, naming the consequence and not just the error.

Release 0.1.17.


Changes since v0.1.16.

v0.1.16

Choose a tag to compare

@github-actions github-actions released this 31 Aug 16:06

Release 0.1.16

Namespace every error code as E__

The convention was announced and not kept: 115 framework codes across ten
packages carried no E_ prefix, so an application filtering on the documented
rule handled some failures and missed others.

Blanket-prefixing them was the wrong fix, and trying it proved why — it made
E_FORBIDDEN mean three different things across ream, relay and warden, which
is worse than the inconsistency it replaced. The namespace after E_ is what
tells them apart.

Where a package already prefixed inside its error constructor — atlas, rune,
warden, and ream's module classes — the constructor now emits E__ and the
call sites stay bare, so the rule lives in one place per package instead of at
every throw. Each of those constructors passes through a code that already
starts with E_, which is how the upstream identifiers keep their exact
spelling: E_UNAUTHORIZED_ACCESS, E_INVALID_CREDENTIALS and E_VALIDATION_ERROR
are the ones a consumer branches on, and two packages naming the same upstream
failure legitimately share one.


Changes since v0.1.15.