v26.8.0-beta.1
Pre-releaseAdded
-
Observability: a correlated lifecycle event stream and an injectable logger. Every request is
given an id — an incomingx-request-idis adopted rather than replaced — and every event of that
request carries it, alongside the matched route pattern (never the concrete URL, which would give
a metrics backend one label per distinct path). Each step reports its own duration.createApp({ logger })accepts any object withdebug/info/warn/error; the default writes structured JSON,
or a readable line on a TTY, decided once at boot. Nothing in core writes toconsole, enforced by a
lint rule rather than by intention.createApp({ logRequests: true })logs one line per request, off
by default. New exports:Logger,LogLevel,LogFields,createDefaultLogger,
withConsoleFallback,logRequests,LifecycleEvent,EventPayload,Correlation.No metrics registry and no OpenTelemetry exporter in core — those live outside it, because core
keeps one runtime dependency. Atraceparentheader is carried through untouched for an exporter to
interpret; core implements no propagation spec. Closes #10. -
A bounded
close()on the Deno and Bun adapters, andcreateApp({ shutdownTimeoutMs })for the
Node one.app.close()returns at its no-server guard on Deno and Bun, so the deadline lives on the
serverserveDeno()/serveBun()returns. One difference the deadline cannot hide: Node and Bun
force the remainder shut, while Deno cannot — aborting a server that is already draining throws from
Deno's own listener, so there the deadline bounds how longclose()waits, not when connections die. -
Shutdown is now an extension point. A
@Providermay declaredispose(), a plugin may call
api.onShutdown(fn), and an application may passcreateApp({ hooks: [{ onShutdown }] })— three
doors into one registry, so an app closing a connection no longer writesprocess.on('SIGTERM')
by hand. Callbacks are awaited, unlikebus.onlisteners, and take no arguments: whatever
needs closing is already in the closure that registered it.They run in reverse boot order, so a
cachethat needsdbcloses before thedbit is holding.
A failing teardown is logged and the rest still run — one broken callback must not leave the
process up. Everything happens insideclose()'s existing deadline;createApp({ teardownTimeoutMs })
reserves a slice of that budget when a connection must get its chance to close, and is rejected at
boot if it exceedsshutdownTimeoutMs.Node, Deno and Bun behave identically — on Deno and Bun the teardown runs from the
close()on the
serverserveDeno()/serveBun()returned. The edge cannot participate: workerd has no
shutdown to intercept, so anything that must be released belongs in the request that acquired it.Nothing changes for existing code.
Plugin's signature is unchanged,Hooksmethods are optional,
anddispose()is called only if present. -
limits.maxConnectionschanges Node's previously unlimited concurrent socket count to a
default cap of1000; values<= 0leave Node unlimited. Deno and Bun have no equivalent
runtime setting and require a platform or reverse-proxy connection cap.
Changed
-
A request that crosses the mesh keeps its identity. The RPC envelope now carries the caller's
requestIdandtraceId, and a teapot adopts them rather than opening a new investigation — the
same rule an incomingx-request-idalready got, applied at the process boundary where a trace
matters most. It also carriesurl, so a proxied handler sees the path its caller asked for.Both fields are optional on the wire and the protocol version does not move:
decodevalidates
only what a frame type requires and passes extras through, so a teapot on an older green-tea
ignores them and keeps answering. That is degraded, not broken. The rule for when the version
does move is now written next to the constant, because "bump on any breaking change" never said
what counts as breaking.The remote-route envelope is also built explicitly instead of cast from the internal request
object, which had been puttingipandprotocolon the wire — fields the protocol never
declared and a teapot could have come to depend on. -
Boot waits for a teapot that is merely slow, and still fails for one that is absent.
createApp({ mesh: { bootTimeoutMs } })gives a teacup a grace period — defaulttimeoutMs, so
30s — in which a teapot that has not finished starting is retried with backoff. When it passes,
the boot still fails, because a provider the graph depends on is not optional: booting without it
would only move the failure to the first request, where it becomes a caller's 503 instead of the
deploy's error.bootTimeoutMs: 0restores a single attempt.A refusal is not retried. A wrong secret or a protocol-version mismatch is the teapot's
decision and will be the same decision in thirty seconds, so it fails immediately rather than
spending the whole budget to reach an identical error. The two are told apart by whether the
socket ever opened — a peer that accepted the connection and then hung up rejected us on purpose;
one that never accepted it may simply not be listening yet.Every retry is logged and emitted as the new
mesh:boot:retrylifecycle event, so a slow boot
is visible to whatever collects events and not only to whoever is watching a terminal. -
.and..in a request path are now resolved rather than 404'd.GET /public/../adminreaches
a route declared as/admin, and%2ecounts as a dot, so the encoded spelling cannot reach a route
the plain one resolves away from. This is a behaviour change on Node only, and it exists to end a
divergence: Deno, Bun and Workers resolve dot segments inside theRequestconstructor before the
framework sees anything, so the same bytes on the wire already reached different routes depending on
where you deployed. Rejecting them — the stricter option, and what this module does for//— is not
implementable on three of the four runtimes. If a proxy or WAF in front of you matches on the literal
path, note that it sees/public/...where the application now routes/admin.
Fixed
-
A mesh export that carried behaviour arrived as
{}, with HTTP 200 and no warning. The wire is
JSON, so a value with methods — a connection pool, a client, aMap— lost everything but its
shape in transit. What reached the caller was an object: truthy, passing anyif (db)check, and
missing every method, so the failure surfaced asdb.query is not a functionat a call site
arbitrarily far from the export that caused it.A teapot now refuses to send one, on the side that still holds the real value, with a message
naming the token and what sat where:mesh cannot transport 'db': result.db is a Pool instance.
The check is an allowlist — primitives, plain objects, arrays — soDateis refused too, since it
would arrive as a string rather than the type the caller declared, which is the same silent
difference in a smaller costume. It is bounded by a scan budget, so a large legitimate payload is
never turned into an error by the cost of checking it.This is a constraint the documentation never stated: a mesh export carries data, never
behaviour. Export what a handle produces, not the handle. -
A mesh teacup now reconnects to a teapot that came back. A dropped link used to stay dead for
the life of the process: every RPC answered 503 until the teacup was restarted, so deploying a
teapot forced a restart of every teacup that depended on it, and boot order became load-bearing.
Links now reconnect with exponential backoff and jitter (500ms doubling to 30s), tunable through
mesh: { reconnect: { initialDelayMs, maxDelayMs } }and disabled withreconnect: false.
close()is terminal — a link the application hung up on never reconnects, soapp.close()cannot
leave a process that refuses to exit.A returning teapot whose manifest no longer exports something the graph was validated against at
boot is refused rather than adopted, named bymesh: { onManifestChange: 'refuse' }, which is
the default and currently the only policy. The link keeps retrying, since a partial deploy may
still restore it, and logs the refusal once per distinct manifest rather than once per attempt.
Serving against a manifest that no longer backs the graph would surface as a 500 that looks like
application code. Extra exports in a returning manifest are ignored: the graph is fixed at boot.This also closes the documented gap where an app-scope export outlived its teapot with a stale
value — a successful reconnect re-registers those bindings, so the next resolve re-runs the RPC.Mesh remains alpha and behind
experimental: true. -
mesh:rpc:errorreported the wire id where every other emitter reports a name. A failing
remote call emittedname: "0"— the per-link request counter — so the teacup's event could not be
lined up with the teapot's event for the same failure. It now names the token or route. -
A teapot now bounds its own handshake and caps the size of a control frame. The teacup has
always timed out its side; the teapot had no equivalent, so an unauthenticated peer could hold a
socket open forever by simply never sendinghello. AnddecoderunsJSON.parseon
peer-controlled input before authentication, with no ceiling below whatever the WebSocket layer
allowed — 100 MiB under thewspackage's defaults. Frames above 4,000,000 characters are now
refused with close code 1009, sized above the 1 MB default body limit a legitimate RPC can carry. -
A
ws://teapot on a non-loopback host now warns at boot. The shared secret travels verbatim
in thehelloframe, so an unencrypted link puts it in front of anyone on the path. A warning
rather than a refusal, since a private network doing its own mutual TLS is a real deployment and
green-tea cannot tell the two apart. -
Buffered response bodies are narrowed to what the host runtime's
Responseaccepts. A Node
Bufferis aUint8Arrayat runtime but its declared backing store admitsSharedArrayBuffer, which
BodyInitdoes not — so Deno's types rejected it. This was a real typing hole on theapp.fetch
path, which is the path Deno, Bun and the edge all use, rather than a JSR formality. -
close()'s shutdown timer is armed beforefinish()is referenced. The previous ordering relied
onserver.close(cb)deferring, which is Node's behaviour rather than a guarantee to us, and left a
ReferenceErrorwaiting in the shutdown path for whoever changed it.