HTTP hosts: Node, Fastify, Hono, Bun, Deno, Workers
AgentBack's REST and MCP surfaces run on any runtime with a
fetch(Request): Response entry point — not just Node/Express.
RestServer.fetchHandler() returns the same routing + Zod validation + DI +
auth + dispatch hooks + confirmation/idempotency + streaming + uploads +
error-envelope pipeline as the Express server, as a runtime-neutral handler.
One app, hosted by whatever owns the port.
A polished, standalone version of this diagram lives at
../architecture/diagrams/http-hosts.html— open it in a browser (with Copy / PNG / PDF export).
diagram source (mermaid)
graph TD
subgraph App["RestApplication — one DI Context"]
Ctrls["@api controllers"]
Tools["@tool classes (MCP)"]
UIs["install* UIs<br/>(explorer, console…)"]
FW["/openapi.json · /llms.txt"]
end
App --> FH["RestServer.fetchHandler()<br/><b>fetch(Request): Response</b>"]
Ctrls --> FH
Tools -->|mountMcpHttpFetch| FH
UIs -->|addFetchPrefix| FH
FW --> FH
FH --> Node["Node http<br/>(listener: 'native')"]
FH --> Fastify["Fastify<br/>installFastifyHost"]
FH --> Hono["Hono<br/>hono.all('*', …)"]
FH --> Bun["Bun.serve({fetch})"]
FH --> Deno["Deno.serve(fetch)"]
FH --> CF["Cloudflare Workers<br/>export default {fetch}"]
Status: the fetch path is at parity with the Express path —
@apiroutes (incl. streaming + uploads), authentication/authorization, dispatch hooks, confirmation/idempotency, the/openapi.json+/llms.txtdocuments, theinstall*UIs, and MCP-over-HTTP (viamountMcpHttpFetch, incl. OAuth bearer + strategy auth). The runnable comparison isexamples/hello-hosts. The Express listener remains the default;rest.listener: 'native'opts into the fetch-driven Node listener. See the root-cutover spec for what stays Express-only (raw@inject(HTTP_REQUEST/HTTP_RESPONSE)routes and dispatch-seam subclasses).
The handler
import {RestApplication} from '@agentback/rest';
const app = new RestApplication({rest: {listen: false}}); // no TCP listener
app.restController(MyController);
await app.start(); // mounts routes
const server = await app.getServer('RestServer');
export const fetchHandler = server.fetchHandler(); // {fetch}
listen: false makes start() wire every route but bind no port — the runtime
owns the listener. fetchHandler() is also exposed in tests via
createTestApp(App).fetch(...) (no socket needed).
How a request flows on the fetch path
diagram source (mermaid)
graph LR
Req["Request"] --> Onion["WebMiddleware onion<br/>(CORS + app.webMiddleware)"]
Onion --> Router["Router.match<br/>(compiled regex)"]
Router -->|api route| RH["RestHandler<br/>auth → authz → confirm →<br/>validate → DI invoke →<br/>output validate → idem"]
Router -->|no match| NF["exact / prefix handlers<br/>(UIs, /openapi.json, MCP)"]
RH --> Res["Response"]
NF --> Res
The same RestHandler runs whether the host is Node, Bun, Fastify, or a Worker —
only the outer adapter (Node↔Web bridge) differs.
Native (no host framework)
rest.listener: 'native' serves fetchHandler() through a Node http server
directly — no Express in the request path. The runtime-neutral Router is the
single source of truth.
const app = new RestApplication({rest: {listener: 'native'}});
app.restController(MyController);
await app.start(); // binds http.createServer(createNodeListener(fetchHandler()))
start() throws if a route opts into Express semantics (raw req/res injection or
a dispatch-seam subclass) — those need the default 'express' listener.
Fastify
import Fastify from 'fastify';
import {installFastifyHost} from '@agentback/rest';
const app = new RestApplication({rest: {listen: false}});
app.restController(MyController);
await app.start();
const server = await app.getServer('RestServer');
const fastify = Fastify();
fastify.get('/native', async () => ({from: 'fastify'})); // Fastify-native route
installFastifyHost(fastify, server.fetchHandler()); // non-greedy fallback
await fastify.listen({port: 3000});
installFastifyHost mounts AgentBack as a non-greedy fallback (a wildcard
route inside an encapsulated plugin scope), so any Fastify-native route or plugin
front-runs it. fastify is a peer/dev dependency — never a hard runtime dep.
Hono
import {Hono} from 'hono';
import {serve} from '@hono/node-server'; // or Bun.serve / Deno.serve
const hono = new Hono();
hono.get('/native', c => c.json({from: 'hono'})); // Hono-native route, runs first
hono.all('*', c => fetchHandler.fetch(c.req.raw)); // everything else → AgentBack
serve({fetch: hono.fetch, port: 3000});
c.req.raw is the underlying WHATWG Request — exactly what fetchHandler
expects.
Bun
Bun.serve({port: 3000, fetch: fetchHandler.fetch});
Bun's server is a fetch host — no adapter, no @hono/node-server. Its fetch
field IS the FetchHost interface.
Deno
Deno.serve({port: 3000}, fetchHandler.fetch);
Cloudflare Workers
Use
EdgeRestApplication. It is the fetch/edge host: pinned tolistener: 'native'(sostart()mounts no Express), and its dependency closure contains noexpress/cors—npm installof anEdgeRestApplicationapp pulls neither the express runtime nor the package.RestApplication(a.k.a.ExpressRestApplication) is the Node/Express host; on a Worker it would try to load the Node-onlyexpressruntime and throw.
import {EdgeRestApplication} from '@agentback/rest';
// Build on the FIRST REQUEST, not at module scope. Constructing the app runs
// the DI container's ID generator, and Workers forbid generating random values
// during global evaluation — a module-scope `new EdgeRestApplication()` throws
// at startup. The cached promise means only the first request pays cold-start.
let booted: Promise<{fetch(req: Request): Promise<Response>}> | undefined;
const host = () =>
(booted ??= (async () => {
const app = new EdgeRestApplication({rest: {listen: false}});
app.restController(MyController);
await app.start(); // collects routes; mounts NO Express
const server = await app.getServer('RestServer');
return server.fetchHandler();
})());
export default {
async fetch(request: Request): Promise<Response> {
return (await host()).fetch(request);
},
};
This is exactly the wrapper agentback deploy cloudflare generates. Construction
is deferred into fetch() because Workers reject randomness/IO at global scope
(see the constraints below); the cached booted promise means subsequent
requests reuse the handler. On Workers, FileStore should be R2 and any
Node-only deps must be avoided in the route handlers.
EdgeRestApplication deliberately omits app.middleware/app.expressMiddleware
(Express-only) — use app.webMiddleware for the runtime-neutral chain. It
supports @api routes, /openapi.json, /llms.txt, and MCP-over-fetch; the
install* UIs (console/explorers) are Express-host-only for now.
Two edge-runtime constraints the bundle doctor cannot catch (it is a static analyzer — bundle-clean ≠ runtime-clean), already handled by the framework but worth knowing if you author module-scope code:
- No "generate random values" / IO / timers in the global (module-load) scope —
Workers reject it at startup validation. Generate IDs inside handlers (the
framework's
generateUniqueIdandcrypto.randomUUID()are call-time safe). nodejs_compatfakesprocess.versions.node— don't gate "am I on Node?" on that alone; also checkimport.meta.urlis afile:URL.
MCP-over-HTTP on any host
installMcpHttp(app) auto-selects the transport for the host: in
listener: 'native' it mounts the fetch-native
mountMcpHttpFetch (the SDK's WebStandardStreamableHTTPServerTransport);
otherwise the Express transport. Either way, the same @tool/@resource surface
is reachable by remote MCP clients, with OAuth bearer or strategy auth.
import {installMcpHttp} from '@agentback/mcp-http';
const app = new RestApplication({rest: {listener: 'native'}});
app.component(MCPComponent);
app.service(MyTools);
await installMcpHttp(app); // POST/GET/DELETE /mcp on the fetch path
await app.start();
To drive it on Bun/Fastify/Hono (where you own the listener), call
mountMcpHttpFetch(mcp, server, options) before app.start(), then host
server.fetchHandler() as above.
Testing the handler in-process
No socket needed — createTestApp exposes a fetch client over the same
handler:
const t = await createTestApp(App);
const res = await t.fetch('/greet/Ada');
expect(res.status).toBe(200);
One handler, every runtime
Node, Fastify, Hono, Bun, Deno, and Workers all take the same
fetch(Request): Promise<Response>, so each deployment is a thin wrapper around
the one fetchHandler. The Zod schemas, OpenAPI, MCP projection, auth, and error
envelopes are identical wherever it runs.
Deploying to Cloudflare Workers with the CLI
The agentback deploy cloudflare command automates the full deploy pipeline:
- Generate — writes
.agentback/deploy/cloudflare/worker.ts(a thin fetch-handler wrapper aroundbuildApp) and mergeswrangler.toml. - Preflight — runs the bundle doctor: esbuild-bundles the generated worker and checks for denied
node:imports (node:fs,node:fs/promises,node:net, …) that the Workers runtime rejects. The fetch path of@agentback/restis edge-safe;fromDisk/serveStaticDir(which pullnode:fs) tree-shake away unless your app actually imports them, so a REST-only app passes. Note: the doctor is static — passing it proves the bundle is clean, not that the worker runs. Uselistener: 'native'(below) and a real deploy to confirm runtime behavior. - Deploy — calls
wrangler deployand parses the*.workers.devURL from the output. - Verify — HTTP-GETs
/openapi.jsonon the live worker to confirm the deployment is serving correctly.
Your
buildAppmust construct anEdgeRestApplication(or aRestApplicationwithrest: {listener: 'native'}) — the Express-host default throws at startup on a Worker (see the Cloudflare Workers section above).
# Install prerequisites
npm install -g wrangler
wrangler login
# Build your app, then deploy
pnpm build
agentback deploy cloudflare --prod
The --dry-run flag stops after preflight (no wrangler invocation), useful in CI to validate that the bundle is Workers-compatible without spending a deploy.
agentback deploy cloudflare --dry-run
The --temporary flag deploys to a throwaway preview account — no Cloudflare signup, account, or token. Wrangler provisions a temporary account on the fly (proof-of-work), deploys, and prints a claim URL; the deployment expires after 60 minutes unless claimed. It is the secretless way to run a real deploy in CI (the --dry-run doctor only proves the bundle is clean, not that the worker runs):
agentback deploy cloudflare --temporary
--temporary only works unauthenticated — wrangler refuses it when you are logged in or CLOUDFLARE_API_TOKEN is set, so the inverse of the normal wrangler login prerequisite applies. Requires wrangler ≥ 4.102.
To inspect the generated files before deploying (eject mode):
agentback deploy cloudflare --eject
# Inspect .agentback/deploy/cloudflare/worker.ts and wrangler.toml, then:
wrangler deploy
Deploying the Express host to a serverless platform (Vercel)
agentback deploy vercel targets a Node serverless function and hands the
platform your Express app, so it's the Node/Express host (not the edge
path). Two things to know:
- Declare
express+cors(andmulterif you use uploads) in your app'sdependencies— they are optional peer deps of@agentback/rest(see the v0.5.0 release notes), so they are not installed transitively. - Static-import them in the function entry.
@agentback/restloads express/cors lazily viacreateRequire(to keep edge builds Express-free), which serverless bundlers (Vercel'snode-file-trace) cannot follow — so the deployed function omits them and crashes withCannot find module 'cors'. The generated entry (agentback deploy vercel) already includesimport 'express'; import 'cors';for this reason; if you hand-write the function or use a different platform, add those side-effect imports yourself.