Heximon Logo
Deploy

Nitro

Run a Heximon app inside Nitro v3 with heximonNitroModule, heximonNitroVitePlugin for the dev loop, heximon.config.ts, NitroFetchClient, and one server directory that deploys to Node, Cloudflare Workers, Deno, Bun, and AWS Lambda.

Host the same Heximon modules inside Nitro and you can deploy them to Node, Cloudflare Workers, Deno, Bun, or AWS Lambda by changing one preset — nothing in your app code changes. Nitro owns the platform (route rules, caching, headers, the live server); Heximon owns the app (the DI graph, validation, error filters). You wire the two together with a single config entry and never write a compile step.

Install the package

pnpm add @heximon/nitro

nitro is an optional peer dependency — you install it as part of your Nitro app, not through Heximon (the workspace pins nitro@3.0.260603-beta). The Heximon package only pulls Nitro in when you actually build against it, so importing the runtime client or the compiler plugin elsewhere costs you nothing. The dev loop below needs one more dev dependency, vite-plus — the same Vite-based toolchain the Vite host uses.

Register the host module and its dev loop

List the bare heximonNitroModule in Nitro's modules. It takes no options — there is nothing to pass, because Heximon config lives entirely in heximon.config.ts at the project root, the single source of truth every host reads identically. Add heximonNitroVitePlugin() in a vite.config.ts next to it, and nitro dev gains the same dev experience the Vite host gives — a build-error page, a JSON-preview page, recompile on every source change, and browser auto-reload. All three files are the recommended setup from day one: a bare nitro dev (module only, no Vite plugin) still serves your app, but without the plugin there is no dev loop — no recompile-on-change, no build-error page, no auto-reload on a source edit:

nitro.config.ts
import { defineNitroConfig } from "nitro/config";
import { heximonNitroModule } from "@heximon/nitro/module";

export default defineNitroConfig({
  compatibilityDate: "2025-07-04",
  serverDir: "server",
  modules: [heximonNitroModule],
});
heximon.config.ts
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";

export default defineHeximonConfig({ plugins: [new HttpPlugin()] });
vite.config.ts
import { heximonNitroVitePlugin } from "@heximon/nitro/vite";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite-plus";

export default defineConfig({ plugins: [nitro(), heximonNitroVitePlugin()] });

That is the whole integration. When you run nitro dev or nitro build, heximonNitroModule loads heximon.config.ts, runs Heximon's compiler over whatever serverDir Nitro is configured with, and mounts the generated routes onto Nitro's server at startup — that part runs with or without the Vite plugin, so a bare nitro dev still serves your app. What the plugin adds is the loop on top: without it, a broken compile serves a stale response instead of the diagnostic page, and a source change needs a manual restart to pick up. You never run a compiler yourself, and there is no entry file or alias to maintain either way.

Two dev-only gaps versus the Vite host, because Nitro owns its own request runner and upgrade socket: no serve-last-good (a broken build shows the error page, not the previous good response), and Heximon-declared WebSocket routes aren't served in this dev path. A production nitro build is unaffected by either.

heximon.config.ts is deliberately the same file every host reads — so moving a project from the dev server to a Nitro deployment is a config swap, not a rewrite. The one difference is the scan root: under Nitro there is no src/ default, because Nitro already owns the serverDir convention. Your Heximon code lives wherever Nitro looks for server code.

Plug in the tiers you use

heximon.config.ts's plugins array is the compiler plugin set, identical to the one every other host reads. The example above is HTTP-only, so it lists just new HttpPlugin(). Add a plugin per tier you want compiled under Nitro.

heximon.config.ts
import { CqrsPlugin } from "@heximon/cqrs/compiler";
import { EventEmitterPlugin } from "@heximon/events/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
import { defineHeximonConfig } from "@heximon/build";

export default defineHeximonConfig({
  plugins: [new HttpPlugin(), new EventEmitterPlugin(), new CqrsPlugin()],
});

You never list a Nitro plugin yourself — heximonNitroModule requires HTTP and adds itself, so a user-listed HttpPlugin collapses with the one Nitro pulls in. List only the extra tiers your app uses; the auto-compile honors the whole set.

Write a host-agnostic controller

Nothing in your app code knows it's running under Nitro. Routes are declared by each handler's action parameter type, and CounterService is injected from the constructor signature — the same controller runs unchanged under the Vite dev server or inside Nitro's request pipeline.

server/counter/counter.controller.ts
import type { Controller, Get, Post } from "@heximon/http";
import { CounterService } from "./counter.service";

export class CounterController implements Controller<"/"> {
  constructor(private readonly counter: CounterService) {}

  public async index(_action: Get<"/">): Promise<{ message: string; hints: string[] }> {
    return {
      message: "Heximon is running inside Nitro.",
      hints: ['POST /track with {"page": "/home"}', "GET /stats"],
    };
  }

  public async track(action: Post<"/track">): Promise<{ page: string; views: number }> {
    const { page } = (await action.request.json()) as { page: string };
    return { page, views: this.counter.increment(page) };
  }

  public async stats(_action: Get<"/stats">): Promise<Record<string, number>> {
    return this.counter.snapshot();
  }
}

The module declares the controller under the http namespace and the service in providers — exactly as it would for any Heximon host. There is no host-specific registration:

server/counter/counter.module.ts
import { Module } from "@heximon/runtime";
import { CounterController } from "./counter.controller";
import { CounterService } from "./counter.service";

export class CounterModule extends Module({
  providers: [CounterService],
  http: { controllers: [CounterController] },
  exports: [CounterService],
}) {}

The root module composes feature modules with imports, and Nitro compiles that graph as its build step:

server/app.module.ts
import { Module } from "@heximon/runtime";
import { CounterModule } from "./counter/counter.module";

export class AppModule extends Module({
  imports: [CounterModule],
}) {}

The base path is owned by your app code, not the host. A Controller<"/"> serves at the root; a Controller<"/api"> mounts every route under /api. There is no Nitro mount prefix to configure — routes land at exactly the paths your controllers declare, so Nitro's per-route rules and OpenAPI scan see each one.

Deploy anywhere Nitro deploys

The same server/ directory targets every Nitro runtime; you pick the runtime by switching the preset at build time. The compiler emits one createApp plus a route table, and Nitro bundles that for whichever target you choose.

PresetRuntime
node-serverLong-running Node.js process
cloudflare_moduleCloudflare Workers / Pages
deno-serverDeno
bunBun
aws-lambdaAWS Lambda

A vendor preset pulls its Heximon platform package on demand — they are optional peer dependencies, so you install only the one matching your target: @heximon/cloudflare for a cloudflare* preset, @heximon/aws for aws-lambda, @heximon/vercel for vercel/vercel-edge, @heximon/netlify for netlify/netlify-edge, @heximon/deno for deno-deploy. A plain Node-class preset (node-server, bun, deno-server, a generic *-edge) needs none. Build against a vendor preset without its package installed and the build fails loud with an install hint, rather than dragging every vendor's toolchain into a single-target app.

CI smoke-tests a real nitro build + boot for every preset above (the nitro-preset-smoke job builds the nitro-presets gap example under each and asserts it serves): node-server / bun / deno-server boot and answer GET /, aws-lambda's handler is invoked with a synthetic event, and cloudflare_module runs the emitted Worker on real workerd (miniflare). For a Cloudflare deployment you can also use @heximon/cloudflare (host-independent wrangler.json emit, the flagship Cloudflare app) with a @heximon/build bundle.

Because the wiring is compiled to plain JavaScript with no eval and no JIT, the same build runs on the edge runtimes that reject reflection-based frameworks — Heximon never needs to inspect a decorator at boot.

The module also reads your preset to set the compiler's build target, so target-aware codegen matches the runtime you deploy to. A V8-isolate / fetch-only preset (cloudflare-*, *-edge, deno-deploy) compiles for edge; every long-lived-process preset (node-server, aws-lambda, vercel, netlify, bun, deno-server, …) compiles for node. The most visible effect is OpenTelemetry: on an edge preset @heximon/otel bakes its lean Workers tracer leg with a per-request ctx.waitUntil flush instead of the heavier Node SDK — no config change, just the preset you already pick. The same mapping applies in nitro dev, so the dev compile matches the deploy runtime.

The Nitro module maps Vercel's and Netlify's presets to the corresponding Heximon strategies automatically: the vercel preset resolves to VercelFunctionsStrategy, and the netlify preset resolves to NetlifyFunctionsStrategy. See Deploy overview for the standalone build path (skipping Nitro) that writes the complete vercel deploy --prebuilt / netlify deploy output tree directly.

Call your own routes in-process

When a provider injects InternalClient, the Nitro host resolves it to a NitroFetchClient that wraps Nitro's own fetch — so a service calling another module's endpoint dispatches in-process, with no network hop out and back. You never wire it: the host supplies it through createApp automatically.

This works for one reason that runs through all of Heximon: class identity is the only DI token. Because NitroFetchClient extends H3FetchClient extends InternalClient, any constructor that asks for InternalClient gets the host's in-process dispatch — no token, no registration, no adapter.

Race-free mount and readiness

The Nitro module solves a cold-start problem common in async frameworks: routes are bound to h3 synchronously at plugin startup — before createApp resolves — so a request that arrives while the app is still booting waits for the boot promise rather than falling through to Nitro's 404. The app is constructed exactly once, regardless of how many requests race in before it resolves.

The boot promise is exposed through heximonBootPromise from @heximon/nitro/module, which returns the single retained Promise<AppHandle> for a running Nitro app. A health route can gate on it to guarantee the app is ready before reporting healthy:

server/health/health.controller.ts
import type { Controller, Get } from "@heximon/http";
import { heximonBootPromise } from "@heximon/nitro/module";
import { useNitroApp } from "nitro/app";

export class HealthController implements Controller<"/"> {
  public async health(_action: Get<"/_health">): Promise<{ status: string }> {
    await heximonBootPromise(useNitroApp());
    return { status: "ok" };
  }
}

heximonBootPromise returns undefined when heximon was never mounted onto the app (no module installed), so a health probe can distinguish a healthy Heximon app from one that never started.

Graceful shutdown

When Nitro receives a shutdown signal (SIGTERM, nitro.close()), the module runs Heximon's fault-tolerant app.shutdown() via Nitro's close runtime hook. Shutdown drains in-flight requests and tears down constructed instances in reverse construction order — a pool's end(), a queue's drain, and so on. The shutdown is AggregateError-collecting: a single teardown failure surfaces as a rejection without leaving the rest of the graph half-torn. No configuration is needed; the hook is registered by the module automatically.

Run it

Nitro's own dev command drives everything — heximonNitroModule.setup compiles your server/ directory before Nitro serves it:

terminal
nitro dev
terminal
curl http://localhost:3000/
# → { "message": "Heximon is running inside Nitro.", "hints": [...] }

curl -X POST http://localhost:3000/track -H "content-type: application/json" -d '{"page":"/home"}'
# → { "page": "/home", "views": 1 }

curl http://localhost:3000/stats
# → { "/home": 1 }

A production build is nitro build against your chosen preset, then run the bundled output the way that preset expects (node .output/server/index.mjs for node-server). Same source, same routes — only the bundle target differs.

Because vite.config.ts registered heximonNitroVitePlugin() back when you set up the host, that same nitro dev already carries the dev loop: edit anything under server/ and the recompiled routes reload on their own, a broken compile shows the diagnostic codeframe in the browser (and a plain 500 to curl/a JSON client) instead of a stale response, and a browser navigation to a JSON route renders the syntax-highlighted preview page.

See also

  • Nuxt — serve the same Heximon routes alongside Vue pages on one Nitro server, with SSR cookie forwarding.
  • Controllers — the two controller modes, middleware, and typed responses the Nitro host serves unchanged.
  • Durable Objects — run Cloudflare stateful actors under compile-time DI once Nitro is your host.
  • WebSockets — layer a real-time transport onto the Nitro host's fetch server.
  • The nitro-presets gap example — the full integration end to end: a bare heximonNitroModule, a host-agnostic counter app, and a test that drives the auto-compile and serves real requests through Nitro's server.
Copyright © 2026