Library Mode
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.
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.
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:
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):
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.
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.
{
"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.
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.
| Artifact | Where | Purpose |
|---|---|---|
create<Module> factory (typed) | dist/heximon.mjs, declared in dist/heximon.d.mts — the ./heximon subpath | The factory the consuming app's compiler links against — and a human can call by hand, with real types |
heximon.manifest.json | dist/ | Records the boundary — the module's requires, exports, and the factory export + its ./heximon specifier, keyed to the package name |
package.json exports map | written in place | The 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.
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:
| Depth | What crosses | How | Examples |
|---|---|---|---|
| Runtime instances | constructed handler objects | the boundary's factory constructs them and self-registers them into the host's dispatch tables at boot | HTTP controllers, CQRS command/query handlers, event handlers, queue channel handlers, saga timeout handlers |
| Manifest facts | data the app's build needs | the app reads the boundary's manifest at compile time and folds the facts into its own build output | a boundary queue channel's delivery trigger on a push platform, a boundary handler's OpenTelemetry span, a boundary's cron triggers |
| Re-discovered concepts | class identities the app must name in generated code | the app synthesizes the class from the boundary's .d.ts + manifest and wires it as if app-local | Durable 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:
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()],
});
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'sexports, 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):
{
"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
Routedefinition, a contractprefix— 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
requiresorexports— the cross-boundary edges the app's compile reads from the manifest — the dev server runs the library's library-mode emit in-process (novp pack, no child process), writes the freshdist/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.
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.
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.
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.
wiringMode | Factory 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/exportsboundary, the barrel, thevp packconfig, and thepeerDependenciessetup that keeps DI identity intact.
Compiler Diagnostics
How a Heximon build error reads — the severity header, the area/slug code, the source codeframe, the help trailer, the dev-server overlay, and the compact CI form.
Other bundlers
Compile a Heximon app and serve its generated wiring across bundlers with @heximon/build — the full dev experience on Vite (including WebSocket-in-dev), and Rollup / Rolldown / esbuild / webpack / rspack build tiers that emit a runnable server bundle from one unchanged src/.