Heximon Logo
Under the Hood

Library Mode

Ship a Heximon module as a precompiled npm package — heximonLibrary(), the typed create<Module> factory, heximon.manifest.json, requires/exports boundary tokens, and peerDependencies.

Ship a feature as an npm package. Library mode compiles one Heximon module — its providers, its boundary tokens, its lifecycle — into an installable package that any Heximon app pulls in through its imports array, exactly like a local module.

The work happens once, at publish time: the build emits a typed create<Module> factory plus a dist/heximon.manifest.json describing the package's DI boundary. The consuming app reads that manifest across the package boundary instead of re-scanning your source — your internal graph ships sealed and prebuilt.

Wire it with the heximonLibrary() plugin in your packaging build. A published module's vp pack config adds it to pack.plugins — the deliberate companion to the heximon() dev/app plugin. The two live in different arrays because they run in different builds: heximon() is a Vite plugin, and vp pack runs on Rolldown, which never consults the Vite plugins array.

vite.config.ts
import heximonLibrary from "@heximon/library/rolldown";
import { defineConfig } from "vite-plus";

// On `vp pack`, heximonLibrary() runs the library emit: the typed create<Module> factory +
// dist/heximon.manifest.json. Its compiler plugins come from heximon.config.ts (below).
export default defineConfig({
  pack: {
    entry: ["src/index.ts"],
    dts: true,
    format: ["esm"],
    plugins: [heximonLibrary()],
  },
});

Building with another bundler instead of vp pack? The same emit runs under Vite / Rollup / esbuild via @heximon/library. Either way, keep dts: true — the factory ships as typed TypeScript, so the declaration step yields a faithful dist/heximon.d.mts.

Define the boundary module

A published package has a single root boundary module. Three keys describe its contract with the host: requires names the tokens the host must route in, providers build the package's own classes, and exports expose what consumers may inject. Everything else in the package stays private.

src/billing.module.ts
import { Module } from "@heximon/runtime";
import { BillingDatabase } from "./billing.database";
import { BillingService } from "./billing.service";

export class BillingModule extends Module({
  requires: [BillingDatabase], // the host routes this token in
  providers: [BillingService], // BillingService injects BillingDatabase
  exports: [BillingService],   // exposed to the consuming app
}) {}

BillingDatabase is the package's own token, declared concrete with a default the host overrides. The package requires it and exports it, but never constructs it across the boundary — the host supplies the instance. That keeps the package agnostic about which database it runs against while still injecting a real one.

Export the surface — the boundary tokens

The consuming app must import the module, its requires tokens, and its exports from one public entry, so both compilers align on the same DI identity. A token resolved through two different import paths is two different tokens. Re-export those — and nothing else:

src/index.ts
export { BillingDatabase } from "./billing.database";
export { BillingModule } from "./billing.module";
export { BillingService } from "./billing.service";

The generated create<Module> factory is not part of this barrel. It ships on the package's dedicated ./heximon subpath, which the build wires for you: heximonLibrary() registers the generated barrel (src/.heximon/library/index.ts, a machine-owned dot-directory next to your entry) as its own pack entry and writes the matching export-map entry into package.json.

No human imports the factory — the consuming app's compiler emits that import into its generated wiring — so the machine-to-machine channel stays out of your hand-written surface.

Anything a consumer doesn't import — billing.service.ts's internals, helpers, private providers — never appears in index.ts. The package's surface is exactly what you re-export here.

Declare your compiler plugins

If your package uses compiler-discovered tiers — HTTP controllers, CQRS handlers, domain event handlers — declare the matching compiler plugins in heximon.config.ts, exactly as an app does for its dev build. heximonLibrary() reads that same file, so the plugin list lives in one place — an app that exports itself as a library repeats nothing (pass heximonLibrary({ plugins: [...] }) to override it):

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

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

With the pack config and heximon.config.ts in place, vp pack runs the compiler in library mode before entries resolve — writing the typed create<Module>.ts factory + the src/.heximon/library/index.ts barrel, registering that barrel as the pack's own heximon entry, and emitting dist/heximon.manifest.json.

The factory ships as the ./heximon subpath (dist/heximon.mjs, declared in dist/heximon.d.mts); your pack.entry stays exactly the hand-written src/index.ts.

Why dts matters: the factory is emitted as typed TypeScript, so the declaration step produces a faithful dist/heximon.d.mts — a human calling createBillingModule by hand gets real argument and result types, not any. The library emit auto-skips if you turn dts off (a build that ships no declarations can't ship a consumable typed factory).

Declare the framework as a peer

The framework and every shared cross-boundary type must be peerDependencies, never bundled. Bundling ships a second copy of Module, and two copies are two different DI tokens — the host's BillingService and yours stop matching, so injection silently fails at runtime. Cover dist/ in files for the same reason of shipping exactly one boundary.

package.json
{
  "name": "@acme/billing",
  "type": "module",
  "files": ["dist"],
  "exports": {
    ".": { "development": "./src/index.ts", "default": "./dist/index.mjs" }
  },
  "scripts": { "build": "vp pack" },
  "peerDependencies": {
    "@heximon/runtime": "^2.0.0"
  }
}

The root . entry you author is the minimum that makes @acme/billing resolvable at all — the app's imports: [BillingModule] imports the module token through it, and the development condition serves the package's source to the workspace before any build has run.

From there the pack maintains the map: it normalizes the root entry, adds the ./heximon factory subpath and ./package.json, restores explicit types conditions, and writes a dist-only publishConfig.exports your package manager swaps in at publish.

Commit the write; re-packs are byte-stable, and extra hand-authored subpaths are preserved. To opt out entirely, set pack: { exports: false } — you then maintain the map by hand, and the build fails loud if the ./heximon entry is missing.

A bundled framework copy breaks DI identity across the boundary.heximonLibrary() resolves tokens by class identity, so two copies of @heximon/runtime produce two unrelated Module tokens and the host can't wire your exports. The build doesn't fail — it warns: [heximon-library] no @heximon/* peerDependency declared — the heximon framework and shared cross-boundary types must be peerDependencies (never bundled), or DI token identity splits across the package boundary at runtime (Guard 1)., and a matching warning when files doesn't cover dist/. Fix it by moving every @heximon/* you import to peerDependencies and listing dist in files.

Read what the build emits

The build produces two artifacts. The factory is the code the consuming app links against; the manifest is the description that lets it link without your source.

ArtifactWherePurpose
create<Module> factory (typed)dist/heximon.mjs, declared in dist/heximon.d.mts — the ./heximon subpathThe factory the consuming app's compiler links against — and a human can call by hand, with real types
heximon.manifest.jsondist/Records the boundary — the module's requires, exports, and the factory export + its ./heximon specifier, keyed to the package name
package.json exports mapwritten in placeThe resolution wiring for both entries — source-linked development conditions for the workspace, types/default for the bundle, publishConfig.exports for publish

The manifest is what makes a precompiled package consumable: it records each cross-boundary token's specifier as the package name, so the host compiler resolves BillingService and BillingDatabase by that bare specifier and never has to see — or recompile — your internals.

Consume the published module

The common path never touches the factory by hand. A host app installs the package and lists the module in imports by its bare specifier, exactly like a local module. The app's compiler resolves the manifest (dist/heximon.manifest.json, or a custom path via the package.json heximon.manifest pointer) and wires the factory in automatically.

src/app.module.ts
import { Module } from "@heximon/runtime";
import { BillingDatabase, BillingModule } from "@acme/billing";
import { PrimaryDatabase } from "./primary.database";

export class AppModule extends Module({
  imports: [BillingModule],
  providers: [PrimaryDatabase],
  // Route the package's required token to the host's concrete database.
  overrides: [{ provide: BillingDatabase, useExisting: PrimaryDatabase }],
}) {}

The host routes whatever the package's requires names — here BillingDatabase, bound to its own PrimaryDatabase — and gains access to the package's exports (BillingService). The precompiled internal graph stays sealed: the app compiler links the factory and never re-scans the package.

Because the factory ships typed, the rarer manual path is also available — import it from the ./heximon subpath and call it with the host-bound inputs:

import { createBillingModule } from "@acme/billing/heximon"; // fully typed

const { exports } = await createBillingModule({ billingDatabase });
// exports.billingService: BillingService

What crosses a boundary, and how

A composed boundary crosses concepts to the consuming app at three depths. You never wire any of them by hand — listing the module in imports (and, for the third depth, registering each concept's plugin) is enough:

DepthWhat crossesHowExamples
Runtime instancesconstructed handler objectsthe boundary's factory constructs them and self-registers them into the host's dispatch tables at bootHTTP controllers, CQRS command/query handlers, event handlers, queue channel handlers, saga timeout handlers
Manifest factsdata the app's build needsthe app reads the boundary's manifest at compile time and folds the facts into its own build outputa boundary queue channel's delivery trigger on a push platform, a boundary handler's OpenTelemetry span, a boundary's cron triggers
Re-discovered conceptsclass identities the app must name in generated codethe app synthesizes the class from the boundary's .d.ts + manifest and wires it as if app-localDurable Objects (their bridges + wrangler bindings), WebSocket + SSE handlers (their upgrade/stream routes)

The first two depths need nothing beyond imports: a boundary's queue handler both drains AND — on a push platform (Vercel / Lambda / Netlify) — gets its delivery trigger, and every boundary command / event / queue handler gets its OpenTelemetry span whenever the app enables @heximon/otel, all automatically. The third depth additionally needs the owning plugin registered in heximon.config.ts, described next.

Durable Objects, WebSocket, and SSE cross transparently

A Durable Object, a WebSocket handler, and an SSE handler are not runtime values the factory can hand over — a DO is a per-instance platform actor constructed through its own generated bridge, and a streaming handler is resolved lazily at dispatch.

So when a boundary ships them, the consuming app re-discovers them off the manifest and emits their bridges, upgrade routes, wrangler bindings, and durable-host glue as if they were app-local — with no per-app listing.

The app opts in the same way it does for any concept: by registering the owning compiler plugin. A boundary that ships Durable Objects behind a typed WebSocket + SSE feed (the flagship's @ticketing/realtime) is composed with just its module in imports and the three plugins in heximon.config.ts:

heximon.config.ts
import { DurablePlugin } from "@heximon/durable/compiler";
import { ServerSentEventsPlugin } from "@heximon/sse/compiler";
import { WebSocketPlugin } from "@heximon/websocket/compiler";

export default defineHeximonConfig({
  // DurablePlugin re-discovers the boundary's Durable Objects (their bridges + wrangler bindings emit
  // app-side); the WebSocket + SSE plugins re-discover its streaming handlers (their upgrade/stream routes
  // emit app-side). Every other plugin the boundary's concepts need is registered the same way.
  plugins: [/* … */ new DurablePlugin(), new WebSocketPlugin(), new ServerSentEventsPlugin()],
});
src/app.module.ts
import { RealtimeModule } from "@ticketing/realtime";

export class AppModule extends Module({
  imports: [RealtimeModule], // its Durable Objects + WebSocket + SSE handlers wire automatically
}) {}

A vp build then emits the boundary's Durable Objects into dist/wrangler.json (durable_objects.bindings

  • the migrations ledger) and its WebSocket/SSE upgrade routes into the worker — the same artifacts an app-local Durable Object or handler produces. The boundary provider its handlers inject (a WebSocketContract) must be in the package's exports, so the app resolves it across the boundary.

The build flags a stale manifest

Because the app trusts the manifest to know what a sealed package exposes, a manifest that drifts from the package it ships with — a hand-edited or out-of-date heximon.manifest.json exporting a token the package no longer actually ships — would compile cleanly and then fail at runtime with a bare no provider.

The app build closes that gap: it cross-checks each boundary module's manifest core tokens (the module class, the create<Module> factory, and the exported tokens the package itself ships) against the names the package's published .d.ts exports, and warns when one is missing:

Cross-package module '@acme/billing': its heximon.manifest.json declares the exported token 'BillingService', but the package's published .d.ts does not export it — the manifest is out of date with the shipped package. Rebuild/republish the package, or align your installed version with its manifest.

It only warns when the package's .d.ts was read and the name is definitively absent — a package that ships no type declarations is skipped (no evidence either way), so a .js-only dependency is never misreported. Escalate the warning to a build-breaking error with strictDiagnostics: [DiagnosticCode.BoundaryManifestDrift] in the heximon() plugin options.

Hot-reload a workspace library in dev

A published npm boundary only changes when you republish it — there is no local source to edit. But a workspace-linked library (a monorepo sibling the app consumes by a workspace:* specifier) is local source, and vp dev hot-reloads it: edit the library, refresh the app — no manual vp pack.

The library side is already wired: the exports map the pack wrote carries a development condition per subpath, pointing at source (the same three-condition shape every framework @heximon/* package uses):

the library's package.json — written by the pack
{
  "exports": {
    ".": {
      "development": "./src/index.ts",
      "types": "./dist/index.d.mts",
      "default": "./dist/index.mjs"
    },
    "./heximon": {
      "development": "./src/.heximon/library/index.ts",
      "types": "./dist/heximon.d.mts",
      "default": "./dist/heximon.mjs"
    },
    "./package.json": "./package.json"
  }
}

The explicit types conditions matter — the app's boundary-drift check resolves the library's declarations types-first, so they keep it pointed at the bundled .d.mts even while development points at source. The pack restores them automatically.

In dev, Vite loads the linked library from its source instead of its bundle, so the dev server reflects a library edit two ways, automatically:

  • Behavioral, route, and contract edits hot-reload for free. A service method body, a controller, a Route definition, a contract prefix — these are runtime values Vite re-evaluates through its own module graph (the library is now source-loaded). Nothing is re-packed.
  • A change to the library's DI surface re-emits in-process. When you change what the library requires or exports — the cross-boundary edges the app's compile reads from the manifest — the dev server runs the library's library-mode emit in-process (no vp pack, no child process), writes the fresh dist/heximon.manifest.json + generated factory, and recompiles the app against it.

The dev server also self-heals the machine-owned artifacts before it loads the app: a missing generated barrel is re-emitted in-process, and a deleted ./heximon export-map entry is restored into package.json (with a logged warning) — for the directly imported libraries and for the workspace libraries they compose, so a hand-cleaned tree never 500s on an unresolvable factory import.

Production is unchanged: a vp build resolves the library's default export (its bundled dist), exactly as a published npm package would. The development condition is a dev-only convenience.

Hot-reload is Vite-only (the live dev server), and applies only to workspace-member libraries — a published node_modules package ships only dist, so there is no source to watch.

Ship more than one root module

A package may publish more than one root module — for example a business module plus a per-dialect persistence module. The build emits one create<Module> factory per root, and the manifest records each root as its own section (the host reads requires/exports/factory per imported class, not just the first root). Nothing extra is required in pack — every module no other module in the package imports becomes a root.

This unlocks the dialect-swap shape: a context package ships its business module and a persistence module per dialect, each exporting the same repository port. The host imports the business module plus the one dialect it wants, and declares no repository binding — the dialect choice is which persistence module it imports.

src/app.module.ts
import { Module } from "@heximon/runtime";
import { CatalogModule, SqliteCatalogPersistence } from "@ticketing/catalog";

export class AppModule extends Module({
  // CatalogModule requires EventRepository; SqliteCatalogPersistence exports it.
  // No host binding — the persistence module's export satisfies the business module's require.
  imports: [CatalogModule, SqliteCatalogPersistence],
}) {}

When a boundary module's requires token has no host provider, the compiler resolves it against the exports of the other boundary modules the app imports — the inter-boundary edge.

A host provider for the same token always wins (the explicit-routing escape hatch from Consume the published module still applies); the sibling-export tier is the fallback.

Two imported modules exporting the same token with no host binding is an ambiguity error — route it with an explicit host binding, or import only one exporter.

A single-root package is unchanged: its manifest keeps one section and the host routes its requires exactly as before. Multi-root + cross-boundary satisfaction is purely additive — older packages and older consumers keep working.

Choose an emission shape

wiringMode controls how the compiler emits the factory. The default builds the package's internal DI graph into one file; lazy mode (experimental) emits a thin spine that import()s an internal construction chunk, so a heavy package's graph can load on first use.

Set it in heximon.config.ts (or inline on heximonLibrary({ wiringMode: "lazy" })). The value is either the "eager" / "lazy" string shorthand or the structured object form { mode: "lazy" } — equivalent today, with the object reserved for future per-build knobs.

wiringModeFactory output
"eager" (default)One byte-identical create<Module> factory
"lazy" (experimental)A thin spine plus an import()-able internal construction chunk (the internal DI graph code-split)

The factory's external contract — create<Module>(inputs) resolving to { exports, lifecycle, contributions } — is byte-stable across both, so the manifest, peer, and coverage checks are identical either way.

The lifecycle handle aggregates the package's internal hooks per phase: init() (run when the composing app loads the boundary), bootstrap() (present when a provider declares onBootstrap — the app collects it and runs it with its own boot hooks, after every onInit; the manifest's hasBootstrap flag also makes the app load the package at boot so the standing work starts without a request), and destroy() (run at shutdown).

The barrel re-exports only the spine; the build follows the spine's import() like any dynamic import, so whether the chunk stays a separate file or is folded back into dist/index.mjs is the bundler's usual size-based call. For most packages "eager" is the right default; reach for "lazy" only when a large internal graph genuinely benefits from deferring construction.

See also

  • Hosts overview — how Heximon apps reach a host, framework hosts plus library mode at a glance.
  • Host an app on Nitro — the app-side counterpart: compile and mount a Heximon app inside Nitro for universal deployment.
  • Package a feature — an end-to-end recipe: structure a feature so its contracts and schemas import from the frontend while controllers and domain stay on the server.
  • Billing module fixture — a runnable published-style package: the requires/exports boundary, the barrel, the vp pack config, and the peerDependencies setup that keeps DI identity intact.
Copyright © 2026