v26.9.0-beta.2
Pre-releaseBreaking
-
serveDeno()andserveBun()are async, returningPromise<DenoServer>and
Promise<BunServeResult>. They now boot the app before they bind, which is whatlisten()has
always done.They did not, and the documented fix was to remember to
await app.boot()first. That made the
correct use of a core helper depend on reading a README, in a framework whose argument is that
order should not be something you have to get right. Forget it and a missing signing key is not a
failed deploy: the boot memo keeps the rejection, so the port binds, every request gets a 500 from
the runtime, and none of them reachonError. The server looks healthy to anything that only
checks whether it is listening.Migration is one keyword, and the value is unchanged:
- const server = serveDeno(app, { port }); + const server = await serveDeno(app, { port });
Both runtimes support top-level
await, so a module that serves at import time needs nothing
else.edgeHandleris deliberately untouched: on workerd there is no startup outside a request,
so there is no earlier moment to move the failure to. -
A plugin is a named object.
Pluginis now{ name, mount(api) }; it was(api) => void.
The name thatplugin:mountedreports came fromfn.name, which is""for an arrow returned
straight from a factory,"plugin"for aconst, and whatever a minifier leaves behind — so the
event that exists to report which plugins mounted reported none of them by name. The name is now
the author's word, a failedmountis reported asplugin "<name>" failed to mount: …with the
original error as itscause, and two plugins sharing a name failcreateApp.Migration is mechanical:
- const jwt = (options) => ({ scope }) => { scope.add(node); }; + const jwt = (options) => ({ + name: options.provides ?? 'jwt', + mount({ scope }) { scope.add(node); }, + });
-
Mesh (alpha): only steps and routes can be exported.
@Provider({ export: true })now fails
the boot, with an error naming the provider and the replacement. A provider is a factory whose
value is the object it builds — a pool, a client, adb— and an object is not what a JSON wire
carries. What did cross was whatever half of it survived serialization, and it arrived as an
app-scope binding: the teacup resolved it once at boot and then served that value for the life of
the process, out of a cache the teapot no longer stood behind. Restarting the teapot, or changing
what it built, changed nothing on the teacup until the teacup itself restarted.Export a
@Stepinstead. It runs on the teapot, per request, and only its result comes back —
which is why the two entries below follow from this one: a remote export now holds nothing between
requests.invalidateRemoteBindings, therebindarray and theonReconnectcallback that drove
them are gone with it. All three existed to throw away a cached app-scope value when a link came
back; there is no longer a cached value, so a reconnected link is simply usable again on the next
RPC. Internal, so nothing importable changed.A teapot that boots today can stop booting, and the replacement is mechanical: the exported
@Providerbecomes a@Stepthat returns what the provider's value carried. -
Mesh (alpha): the manifest carries step names.
{ scopes: [{ token, scope }], routes }is now
{ steps: string[], routes }. Every export is request-scope after the change above, so the
lifetime field had one possible value left and told a reader nothing. A manifest step that is not
a string is now rejected on arrival, rather than trusted because a peer sent it.MESH_PROTOCOL_VERSIONdeliberately stays at1. Mesh is alpha behindexperimental: true, both
peers ship from this repository, and there is no deployed pair of versions for a bump to protect —
it would spend the number on nobody. A stable wire would not get that option; alpha is exactly
what the word buys, and this is the last comfortable moment to use it.
Added
app.boot()runs the provider factories now instead of on the first request.listen(),
serveDeno()andserveBun()all call it for you (see Breaking, below). Call it by hand when you
driveapp.fetch/app.upgradefrom your own server: those boot lazily, and the boot memo keeps
a rejection, so a bad key answers 500 on every request, from the runtime, without reaching
onError. On workerd there is no startup outside a request, so calling it moves nothing.- Errors are recognised by brand.
HttpErrorandValidationErrornow carry
Symbol.for('green-tea.http-error')andSymbol.for('green-tea.validation-error'), and
isHttpErrorchecks the brand instead ofinstanceof. An error thrown by code holding a
different copy of core — a plugin package, an app that installed from npm and JSR both — now
renders with its own status instead of a 500. The exportedHttpErrorLiketype and the
isValidationErrorguard come with it. The string is the public protocol: code that imports only
types writesSymbol.for('green-tea.http-error')itself.
Changed
-
Mesh (alpha): an unreachable teapot no longer stops a teacup from booting. Blocking was never
caution, it was arithmetic: an app-scope value has to resolve at boot, because there is no later
to resolve it in. A step is nothing but later. ThebootTimeoutMsgrace still waits, since "the
container is thirty seconds behind" and "the teapot does not exist" look identical for the first
thirty seconds; exhausting it now warns and starts without that teapot.What that buys is narrower than it sounds, and worth stating exactly. A teapot that never
connected sent no manifest, so the teacup learned nothing about it — no step runners, no routes,
a graph identical to the one it would have had if that teapot were never configured. Its routes
therefore 404, through the ordinary unmatched-route path, because nothing was ever registered
to match. And any local step or handler that needs one of its tokens still fails the boot,
naming the teapot that did not connect.503is what a teapot that connected and later died
answers: that link exists, its steps and routes are registered, and the dead link is what returns
the status. Never reachable and reachable-then-gone are different situations, and they read
differently on purpose.So the change is for the teacup that does not depend on that teapot: it starts, rather than
refusing over a dependency it never had. It is not graceful degradation of the dependency, and
cannot be — answering503for an absent teapot's tokens means knowing what it would have
exported, and only a declaration can say. That declaration is theexpectslist in
docs/plans/2026-08-18-mesh-degrade-plan.md, which is planned and not built. Two things are
unchanged: a permanent refusal — a wrong secret, a protocol mismatch — still fails the boot
without spending the grace, because it is the teapot's decision rather than the network's and will
be the same decision in thirty seconds; and the missing-token error, which now says "local or
connected mesh" rather than "local or mesh", the old wording having claimed a search that never
happened.