Skip to content

v26.9.0-beta.2

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 23 Sep 02:04

Breaking

  • serveDeno() and serveBun() are async, returning Promise<DenoServer> and
    Promise<BunServeResult>. They now boot the app before they bind, which is what listen() 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 reach onError. 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. edgeHandler is 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. Plugin is now { name, mount(api) }; it was (api) => void.
    The name that plugin:mounted reports came from fn.name, which is "" for an arrow returned
    straight from a factory, "plugin" for a const, 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 failed mount is reported as plugin "<name>" failed to mount: … with the
    original error as its cause, and two plugins sharing a name fail createApp.

    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, a db — 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 @Step instead. 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, the rebind array and the onReconnect callback 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
    @Provider becomes a @Step that 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_VERSION deliberately stays at 1. Mesh is alpha behind experimental: 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() and serveBun() all call it for you (see Breaking, below). Call it by hand when you
    drive app.fetch / app.upgrade from 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. HttpError and ValidationError now carry
    Symbol.for('green-tea.http-error') and Symbol.for('green-tea.validation-error'), and
    isHttpError checks the brand instead of instanceof. 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 exported HttpErrorLike type and the
    isValidationError guard come with it. The string is the public protocol: code that imports only
    types writes Symbol.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. The bootTimeoutMs grace 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. 503 is 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 — answering 503 for an absent teapot's tokens means knowing what it would have
    exported, and only a declaration can say. That declaration is the expects list 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.