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).

Architecture diagram — text source below
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 — @api routes (incl. streaming + uploads), authentication/authorization, dispatch hooks, confirmation/idempotency, the /openapi.json + /llms.txt documents, the install* UIs, and MCP-over-HTTP (via mountMcpHttpFetch, incl. OAuth bearer + strategy auth). The runnable comparison is examples/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

Architecture diagram — text source below
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 to listener: 'native' (so start() mounts no Express), and its dependency closure contains no express/corsnpm install of an EdgeRestApplication app 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-only express runtime 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:

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:

  1. Generate — writes .agentback/deploy/cloudflare/worker.ts (a thin fetch-handler wrapper around buildApp) and merges wrangler.toml.
  2. 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/rest is edge-safe; fromDisk/serveStaticDir (which pull node: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. Use listener: 'native' (below) and a real deploy to confirm runtime behavior.
  3. Deploy — calls wrangler deploy and parses the *.workers.dev URL from the output.
  4. Verify — HTTP-GETs /openapi.json on the live worker to confirm the deployment is serving correctly.

Your buildApp must construct an EdgeRestApplication (or a RestApplication with rest: {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: