Essay · 9 min read
B4.run at the edge: The Hono target
B4.run's opt-in Hono target emits a Node-free Cloudflare Workers entry with request-scoped Postgres stores and a tested workerd subset.
The first request to the edge build worked. The second request hung.
That failure showed something a successful bundle couldn't: a database connection created during one request was being reused during another. The code looked fine for a long-running Node server, but its lifetime was wrong for the edge runtime.
Let's use that bug to walk through B4.run's Hono build target. We'll look at the generated files, how a request uses storage and which parts of an agent application fit this environment.
Version note: This post covers the edge work through 0.8.21. The configuration examples use the later @b4run package names, first published at 0.8.27, so use the current edge deployment guide to install. The runtime evidence here comes from local workerd tests, not a live Cloudflare deployment.
What is an edge runtime?Copy link to section: What is an edge runtime?
For this post, the target is Cloudflare Workers, which runs application code in workerd. An isolate is the JavaScript environment where the Worker handles requests. It can handle more than one request, but it isn't a Node process, so the full filesystem and process APIs aren't there.
B4.run's HTTP core accepts a web-standard Request and produces a Response. Hono supplies the web application around that handler. The build target generates the route imports, storage setup and Hono entry that connect the pieces.
Of course, standard Request and Response types don't make every dependency or feature edge-compatible. We still need to know what the application imports and how it uses resources.
GoalsCopy link to section: Goals
The edge target needs to:
- Produce modules that the deployment bundler can inspect.
- Run the framework's request handler without Node compatibility enabled.
- Store conversation state outside the Worker.
- Create and clean up database resources within the request that uses them.
- Report unsupported framework capabilities before deployment.
The 0.8 overview covers the wider framework. Here we focus on what changes when the runtime moves to Workers.
Select the Hono targetCopy link to section: Select the Hono target
Add hono to the build targets in b4.config.ts:
import { config } from "@b4run/cli"
export default config({
build: { targets: ["node", "langsmith", "hono"] },
})Setting build.targets replaces the default target list. Here we keep the Node and LangSmith outputs and add Hono. If you only need some targets, list just those.
The build generates these files:
| File | What it does |
|---|---|
.b4/build/modules.edge.mjs | Lists the route, tool, state and middleware modules as static imports, with generated schemas included. |
.b4/build/stores.mjs | Creates the request's database pool and the stores that share it. |
.b4/build/app.mjs | Exports a Hono app that forwards requests to B4.run's handler. |
wrangler.toml | Configures the Worker entry. The root scaffold is created only if one doesn't exist yet. |
These are generated source files. Wrangler, Cloudflare's development and deployment tool, bundles them with their dependencies for the Worker.
When do we rebuild?Copy link to section: When do we rebuild?
If you change code inside a route or tool the manifest already imports, Wrangler bundles the updated module. Changes to discovery or generated configuration need another B4.run build. For example, rebuild after adding or renaming a tool, changing its input type, selecting another provider or changing configuration that is written into the generated entry.
The serializable configuration is included in app.mjs. Supply credentials through the host's secret bindings so they stay out of generated source.
Make imports explicitCopy link to section: Make imports explicit
A Node application can use APIs such as node:fs and globals such as Buffer. A Worker without Node compatibility can't assume those APIs exist.
B4.run checks two things in its edge output:
- The generated entry and reachable framework runtime have no
node:import dependencies. - The framework's own code avoids bare Node-only globals such as
process,Bufferorrequire.
The second check matters because process.env needs no import, so checking import paths alone would miss it.
Provider configuration uses a runtime environment helper. On Node it reads the process environment. In workerd it reads bindings supplied by the generated entry. Either way, the model layer finds credentials and settings such as OPENAI_BASE_URL.
Why static provider imports?Copy link to section: Why static provider imports?
The build also finds the model providers the application uses and generates imports that name their packages. Wrangler follows those imports to decide what belongs in the bundle.
If the build can't inspect a route or map its model to a provider, it fails with an error. Guessing a package name during a request would leave the bundler without a reliable dependency list.
These checks cover B4.run's generated code and framework runtime. Your own tools can still import a Node-only dependency, so bundle and run the real application to catch those.
Follow a request through storageCopy link to section: Follow a request through storage
The Worker needs somewhere to save the conversation and execution state. This target uses Postgres through the Neon serverless driver's WebSocket Pool.
Three stores share the pool:
- The checkpointer saves execution state so a run can continue.
- The thread store retains conversation records.
- The permission store retains permission grants.
These are runtime stores. The typed long-term memory collection declared by memory.ts lives elsewhere.
The connection-lifetime bugCopy link to section: The connection-lifetime bug
The first implementation created a pool at module scope. In a long-running server, a pool usually lives across requests so connections can be reused.
Here is what happened in workerd:
- Request one created a connection and used it successfully.
- The connection returned to the pool when the database operation finished.
- Request two received that idle connection.
- The socket still belonged to request one's completed I/O context.
- Request two waited until workerd cancelled the operation.
A later request could open a fresh connection and work again, which produced the alternating success and hang. One successful request could never prove the design correct. We needed to enter the same isolate from several request contexts.
Create a pool for each requestCopy link to section: Create a pool for each request
The generated target now creates a pool for each incoming request and gives that pool to its three stores. Here is the sequence:
- Receive the HTTP request and its environment bindings.
- Create the request's Postgres pool and stores.
- Run the runtime request with those stores.
- Wait for the response body and any run started by the request to finish.
- Dispose the request's pool.
The fourth step is easy to miss. A streaming response may keep producing data after the handler returns a Response object, and run bookkeeping can outlive that return too. Closing the pool right away would remove a resource still in use.
Initialize the database schemaCopy link to section: Initialize the database schema
Once database migrations finish, the generated target remembers that in a boolean at module scope. Later requests create stores with assumeMigrated.
Unlike the connection pool, this boolean holds a completed result. It holds no socket or other request-bound I/O.
Two initial requests can both reach the migration step before the boolean is set. The migration transactions use Postgres advisory locks to coordinate that case.
The tested connection path uses a local Postgres database behind a local WebSocket proxy. It uses the driver's WebSocket pool. The driver's HTTP query function, a raw TCP pg connection, Neon Cloud and Cloudflare Hyperdrive are all untested.
Check the application's capabilitiesCopy link to section: Check the application's capabilities
Some agent features depend on a filesystem, shell, container or object that can't be serialized into an edge build.
The capability check reports B4_E1005 for unsupported configurations. The edge work described here rejects:
- Execution sandboxes.
- Filesystem or shell backends.
- An app-level
workspace/directory. - Route skills that load instructions from disk.
- Typed long-term memory declared in
memory.ts. - Custom live instances of checkpointer, thread, permission or memory stores.
The current target also rejects tool-output offloading, a restriction added after the 0.8.21 work. Use the current capability list when assessing a new application.
The check reports all detected violations together and points to the configuration or file involved. That beats finding a missing feature during a request.
The check has limits. Route marker files such as plan.md and memory.md may stay in the tree, but their filesystem readers are inactive here. So a passing build check doesn't mean file-backed planning or memory works at the edge.
For an agent that edits files or runs commands, the Node and Docker target is a more direct starting point.
Test more than the bundleCopy link to section: Test more than the bundle
The workerd test builds an OpenAI agent fixture and starts the generated output with wrangler dev --local. It uses the emitted Wrangler configuration without nodejs_compat, a local model fixture and the local Postgres WebSocket path.
The test then sends four sequential AG-UI turns through the same isolate. AG-UI is the event protocol the browser-client adapter uses.
Why four turns? Each request must reload conversation state saved by earlier requests, and each one is a fresh request context. That is what exposed the connection-lifetime bug.
The test also checks database rows over a separate connection. It verifies that thread, checkpoint and write records were persisted. The text returned to the client isn't enough on its own.
Here is what that evidence covers:
| Check | What we learn |
|---|---|
| Bundle and load the generated entry | This fixture and its provider imports can load in workerd. |
Call /healthz | The runtime can respond to its health route. |
| Complete four AG-UI turns | This conversation can continue across request contexts. |
| Inspect persisted records | The expected runtime data reaches Postgres. |
Here's what the workerd lane leaves untested:
- Agent Protocol flows, tool calls, subagents and human approvals.
- Other model providers and a live Cloudflare deployment.
- Production connection, subrequest, upload, bundle and startup CPU limits.
- Remote database latency and cross-isolate cold starts.
Running the same output on Vercel, Deno or Bun would need its own testing.
If you're considering this target, those are good next tests. Start with the features and traffic pattern your application actually uses.
Try the target locallyCopy link to section: Try the target locally
Follow the edge deployment guide to install matching current B4.run packages, the Neon driver, Hono and Wrangler. Configure the database and model-provider bindings it describes.
With the target selected, check and build the application:
pnpm exec b4 check
pnpm exec b4 build --clean
pnpm exec wrangler dev --localSend more than one request. Continue a conversation, exercise each tool and check that state survives between requests. A successful health response alone won't tell you whether the application works.
Then test a deployment with the database, credentials and limits of the host you intend to use. The local workerd test can't stand in for that step.
ConclusionCopy link to section: Conclusion
The Hono target runs B4.run's request handler in a web-standard environment with an explicit list of supported features.
The most useful lesson came from the second request. A valid bundle and a successful first response didn't tell us our storage lifetime was correct. Following the connection through several requests did.
If your agent fits the target's constraints, take the same approach: inspect the generated files, know which resources belong to a request and test the whole conversation in the environment where it will run.