← Back to the blog

Trust is a feature, and I had shipped it as a TODO

There is a difference between a framework you can demo and a framework you can run, and for most of this year green-tea was firmly the first one.

The demo was good. Declare what a route needs, get a validated execution graph, run only the slice that route requires. That part works and I still think it is the right idea. But when someone asked me the question every serious evaluation asks in week one — what is it doing in production? — the honest answer was: I don’t know, and neither will you.

26.8.0-beta.1 is about that gap and the two others hiding behind it.

“No observability layer” was a sentence in my README for months

I want to be precise about how bad that was, because I was oddly proud of having written it down.

Declaring a gap is not the same as not having one. Every request that went through green-tea was anonymous. There was no id to correlate a slow response with the step that caused it, no per-step timing, and — worst of the three — the framework wrote to console in a handful of places, which means an application had no way to redirect its own framework’s output. You could ship it, but you could not operate it.

What shipped is a correlated event stream: an id per request, adopted from an incoming x-request-id when a gateway already made one, carried on every event, alongside the matched route pattern rather than the concrete URL. That last detail matters more than it looks — pattern cardinality is bounded, URL cardinality is not, and a metrics backend fed the second one falls over.

The console calls are gone, and what replaced discipline is a lint rule over src/**. I do not trust myself to remember, and neither should you.

What I did not build: a metrics registry, an OpenTelemetry exporter. Both were in the original issue and both would have been the second runtime dependency this project has. A traceparent header is carried through untouched so an exporter package can do that job. The framework ships the contract; the integration is somebody’s package, possibly mine, later.

I measured the cost instead of guessing at it — about 7% of the framework’s own per-request work, roughly 2% once a real socket is in the path. I mention the number because the first three times I measured it I got three different wrong answers, one with the sign inverted. That is a story for another post.

Shutdown: the framework was holding things it never let go of

The second gap was quieter. Green-tea would boot your providers, hand you a database pool, and then, at shutdown, do nothing about it. app.close() drained requests and closed streams — it never told your code that the process was going away. So every application built on it re-implemented the same process.on('SIGTERM') block, and every one of them got the ordering slightly wrong, because the order you need is the reverse of the order things booted in and nothing was telling you what that order was.

The framework already knew it. It computes a topological order to run your graph. Using it for teardown was not a feature so much as an admission that the information was already sitting there.

Now a provider closes its pool in dispose(), a plugin registers onShutdown, an application passes hooks, and all three land in one registry that awaits them in reverse boot order inside the existing deadline. A failure gets logged, and the rest still run.

The part I want to flag is the one that is not symmetric: on the edge, none of this exists. Workerd has no shutdown to hook into. I could have quietly no-opped it and let the docs imply parity. It says so instead.

The bug I found by looking, and the bug CI found by not trusting me

While profiling the router I noticed GET /public/../admin returned 404 on Node. On Deno, Bun and Workers, the same request reached /admin. The fetch-based runtimes resolve dot segments inside the Request constructor, before my code ever sees the path.

So the same bytes on the wire were routing differently depending on where you deployed. A framework whose entire pitch is the same application model everywhere had a silent routing divergence, and my own cross-runtime parity tests could not catch it, because they go through fetch() — which normalizes the path before it hits the wire. The new tests write the request line to a socket by hand.

The second one is more embarrassing and more useful. CI failed the observability work on Node 18 with ReferenceError: crypto is not defined. The global crypto landed in Node 19. My engines field promises Node 18. Every machine I own runs Node 20 or newer, and the other three runtimes have the global — so I had shipped code that made the framework unusable on its own declared floor, and nothing I could run locally would ever have told me.

That job exists because I pinned it to the version I promise rather than the version I use. It is the single most valuable thing in my CI configuration.

Then I pointed the same question at the part I am proudest of

Mesh is the thing green-tea has that nothing else does: @needs('billing') resolves the same whether billing runs in this process or on another machine. It is the idea I would put on a poster. It is also the part nobody has run in anger, which — after a day of finding out what “we never checked that” means — made it the obvious next place to look.

I found eight defects in about six hundred lines. Not one had been reported; all of them came from going and looking.

The worst is the one that answers a question I had never thought to ask: what happens if you export a database connection? The wire is JSON. A pool has methods and private state and JSON keeps neither, so it serialized to {} — and {} is an object. It passed every if (db) check, it had no methods, and it failed as db.query is not a function at a call site with no visible relationship to the export that caused it. Status 200. No warning at either end, at any point.

That is not a bug in the transport. It is a rule the framework never stated: a mesh export carries data, never behaviour. Export what the handle produces, not the handle. Writing that sentence down is worth more than the guard that now enforces it, and the fact that it took me until now to write it is the honest part of this story.

Second worst: a dropped link never reconnected. Not “reconnected badly” — there was no reconnection code at all. Deploying a teapot meant restarting every teacup that depended on it, which turns a routine deploy into a coordinated one and makes boot order part of your architecture. The framework was quietly asking for an operational discipline it never mentioned.

And the one that stung, because it was fresh: the request id I had just spent a release adding died at the process boundary. Every request got an identity, every event carried it, and the moment the request crossed to another node the far side opened a new investigation. The one place a distributed trace is the entire point was the one place it did not reach.

So mesh stays alpha, and I want to be precise about why, because “still alpha” usually means “not finished”. This is not that. The protocol is versioned and peers refuse each other on mismatch. The secret is compared in constant time. Frames are shape-validated before they reach anything. Ambiguous routes fail the boot rather than picking one. That is not careless code.

The label is not about the code. It is about how much I know. Everything above was found by looking, which means the supply of things to find is set by how long I look, not by how many users have complained. What a day added to what I know was almost entirely here is another thing distributed systems do to you. It comes out of alpha when somebody runs it with two real services, redeploys the teapot on a Tuesday, and tells me what broke — not when I run out of ideas.

The part I actually want to write

Two people contributed code to this release. I want to be specific about why that matters, and it is not politeness.

This project had no stars, no discussion threads, and no visibility. Contributing to a project in that state buys you nothing — no reputation, no line on a résumé anybody has heard of, no guarantee the maintainer will even reply. You are betting your evening on a stranger’s repository on the strength of the idea alone. I have been on the other side of that decision and I usually decided not to.

@YxnnXriel added a timeout to app.close(). On the surface: one option. In practice, every shutdown guarantee in this release is standing on it. The app-wide shutdownTimeoutMs, the bounded close() on Deno and Bun, the teardown budget that stops a slow dispose() from holding a deploy hostage — all of it is built on the deadline that pull request introduced. I did not plan that. It just turned out to be the piece everything else needed.

@hgshreyas capped concurrent connections on Node, which had been unbounded — the framework had no answer whatsoever to load it could not serve. Then followed it with the fix that makes a non-positive value mean unlimited rather than zero, which is what anyone typing 0 actually intends. The review on that one taught me something about my own architecture: the counting cannot live in the shared app.fetch path, because app.fetch is not on Node’s listen() route. I did not know that cleanly until someone else’s patch forced me to say it out loud.

Both of them are still working on things. One of those pull requests is currently blocked on me — it needs a piece of plumbing that does not exist yet, and telling a contributor “your change is right, the missing part is mine” is a better use of a review than asking them to work around it.

If you are looking for somewhere to start, the honest answer today is that both open issues are already spoken for — I would rather tell you that than have you find out after cloning. So open one instead. Whatever annoyed you in the first ten minutes is usually a real bug, and I have yet to receive a report that was not worth having. The contributing guide covers the branch model and the sign-off, and if you would rather not write the documentation change, say so and I will. Being early costs something. I would rather say thank you in a way that names what you built than in a badge.

Where this leaves the beta

The graph is settled. The plugin API and createApp’s options still move. Mesh is still alpha. The API freeze belongs to the release candidate, and freezing it before this release would have frozen a framework that still could not tell you what it was doing.

Try it

npm install @green-tea/core@beta reflect-metadata

Everything above, in the form of a list rather than a story, is on the 26.8.0-beta.1 release.

That’s the tea. 🍵

← Back to the blog